Compare commits
57
Commits
32c209710c
...
main
+5
-518
@@ -1,522 +1,9 @@
|
||||
# Documentación del Sistema de Laboratorio
|
||||
# Obsoleto
|
||||
|
||||
> **Sistema**: Laboratorio Clínico — Módulos de Agendamiento y Formularios
|
||||
> **Última actualización**: 27/03/2026
|
||||
Este documento quedó desactualizado y se conserva solo por historial de git.
|
||||
|
||||
---
|
||||
La documentación vigente está **dentro del sistema**, en el módulo Soporte:
|
||||
|
||||
## Tabla de contenidos
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
|
||||
1. [Módulo de Agendamiento (Domicilios)](#1-módulo-de-agendamiento-domicilios)
|
||||
- [¿Qué es?](#qué-es)
|
||||
- [Archivos involucrados](#archivos-involucrados)
|
||||
- [Base de datos](#base-de-datos)
|
||||
- [Roles y permisos](#roles-y-permisos)
|
||||
- [Flujo de estados](#flujo-de-estados)
|
||||
- [Funcionalidades](#funcionalidades)
|
||||
- [API Endpoints](#api-endpoints)
|
||||
2. [Módulo de Formularios](#2-módulo-de-formularios)
|
||||
- [¿Qué es?](#qué-es-1)
|
||||
- [Archivos involucrados](#archivos-involucrados-1)
|
||||
- [Base de datos](#base-de-datos-1)
|
||||
- [Roles y permisos](#roles-y-permisos-1)
|
||||
- [Flujo completo](#flujo-completo)
|
||||
- [Tipos de campos del Builder](#tipos-de-campos-del-builder)
|
||||
- [Firma digital](#firma-digital)
|
||||
- [Enlace público y vigencia](#enlace-público-y-vigencia)
|
||||
- [PDF y visualización del documento](#pdf-y-visualización-del-documento)
|
||||
- [Sello de integridad SHA-256](#sello-de-integridad-sha-256)
|
||||
- [Firma del profesional](#firma-del-profesional)
|
||||
- [API Endpoints](#api-endpoints-1)
|
||||
|
||||
---
|
||||
|
||||
## 1. Módulo de Agendamiento (Domicilios)
|
||||
|
||||
### ¿Qué es?
|
||||
|
||||
El módulo de agendamiento gestiona los **servicios de toma de muestras a domicilio**. Permite crear, asignar, seguir y completar visitas médicas domiciliarias. Los administradores gestionan la agenda desde `lab_domicilios.php`; los enfermeros gestionan su propia agenda desde `enfermero_portal.php`.
|
||||
|
||||
---
|
||||
|
||||
### Archivos involucrados
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---|---|
|
||||
| `lab_domicilios.php` | Vista principal del admin — tabla con filtros, detalle del domicilio, formularios recibidos, exportar CSV |
|
||||
| `enfermero_portal.php` | Portal exclusivo del enfermero — su agenda personal del día, ordenada por hora, con tarjetas colapsables |
|
||||
| `lab_enfermeras.php` | CRUD del personal de enfermería |
|
||||
| `classes/lab/Domicilio.php` | Clase ORM — crear, editar, cambiar estado, estadísticas |
|
||||
| `classes/lab/Enfermera.php` | Clase ORM — CRUD, agenda por enfermero, carga de trabajo |
|
||||
| `classes/lab/Asignacion.php` | Clase ORM — asignar / reasignar / liberar enfermero a domicilio |
|
||||
| `api/lab/get_domicilios.php` | GET — lista de domicilios con filtros y estadísticas del día |
|
||||
| `api/lab/save_domicilio.php` | POST — crear, actualizar o cambiar estado |
|
||||
| `api/lab/update_domicilio_enfermero.php` | POST — el enfermero avanza el estado desde su portal |
|
||||
| `api/lab/my_agenda.php` | GET — agenda del enfermero actualmente autenticado |
|
||||
| `api/lab/save_asignacion.php` | POST — asignar o reasignar enfermero |
|
||||
| `api/lab/get_asignaciones.php` | GET — asignaciones por fecha |
|
||||
| `api/lab/registrar_pago.php` | POST — registrar pago de un domicilio |
|
||||
| `api/lab/save_servicio_extra.php` | POST — agregar servicio realizado durante la visita |
|
||||
| `api/lab/get_notas_domicilio.php` | GET — notas clínicas y libres del domicilio |
|
||||
| `api/lab/upload_nota_imagen.php` | POST — subir imagen adjunta a una nota |
|
||||
| `api/lab/crear_desde_whatsapp.php` | GET/POST — crear paciente u orden desde una conversación de WhatsApp activa |
|
||||
|
||||
---
|
||||
|
||||
### Base de datos
|
||||
|
||||
#### Tabla `lab_domicilios`
|
||||
|
||||
Tabla principal. Cada fila es un servicio a domicilio.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---|---|---|
|
||||
| `id` | INT PK | Identificador único |
|
||||
| `paciente_id` | INT FK | Paciente al que se le realiza el servicio |
|
||||
| `orden_id` | INT FK NULL | Orden médica adjunta (opcional) |
|
||||
| `direccion` | TEXT | Dirección completa de la visita |
|
||||
| `ciudad` | VARCHAR(100) | Ciudad |
|
||||
| `barrio` | VARCHAR(100) | Barrio |
|
||||
| `indicaciones_dir` | TEXT | Referencias o indicaciones adicionales ("apto 302, tocar campanilla") |
|
||||
| `fecha_programada` | DATE | Fecha de la visita |
|
||||
| `hora_programada` | TIME | Hora de la visita |
|
||||
| `tipo_servicio` | VARCHAR(100) | Tipo de servicio (toma de muestra, etc.) |
|
||||
| `tipo_cliente` | ENUM | `particular` / `seguro` / `eps` |
|
||||
| `examenes_solicitados` | TEXT | Lista de exámenes (cuando no hay orden médica) |
|
||||
| `seguro_nombre` | VARCHAR | Nombre del seguro o EPS |
|
||||
| `autorizacion` | VARCHAR | Número de autorización |
|
||||
| `valor_domicilio` | DECIMAL | Valor del servicio |
|
||||
| `valor_copago` | DECIMAL | Copago a cargo del cliente |
|
||||
| `copago_laboratorio` | DECIMAL | Copago al laboratorio |
|
||||
| `pago_estado` | ENUM | `pending` / `pagado` / `exento` |
|
||||
| `pago_modo` | ENUM | `efectivo` / `transferencia` / `otro` |
|
||||
| `pago_monto` | DECIMAL | Monto pagado |
|
||||
| `pago_fecha` | DATETIME | Fecha del pago |
|
||||
| `pago_notas` | TEXT | Notas sobre el pago |
|
||||
| `estado` | ENUM | Ver [Flujo de estados](#flujo-de-estados) |
|
||||
| `motivo_cancelacion` | TEXT | Motivo si fue cancelado (obligatorio) |
|
||||
| `fecha_reprogramada` | DATE | Nueva fecha si fue reprogramado |
|
||||
| `hora_llegada` | TIME | Registrada automáticamente al iniciar la visita |
|
||||
| `hora_salida` | TIME | Registrada automáticamente al completar |
|
||||
| `observaciones` | TEXT | Observaciones del resultado de la visita |
|
||||
| `muestras_tomadas` | TEXT | Lista de muestras obtenidas |
|
||||
| `notas_admin` | TEXT | Notas internas del equipo administrativo |
|
||||
| `creado_por` | INT FK | Usuario que creó el registro |
|
||||
|
||||
#### Tabla `lab_enfermeras`
|
||||
|
||||
Personal de enfermería disponible para asignación.
|
||||
|
||||
Campos: `id`, `numero_documento`, `tipo_documento` (CC/CE/TI/PA), `nombre_completo`, `telefono`, `telefono_alt`, `email`, `zona`, `notas`, `is_active`.
|
||||
|
||||
#### Tabla `lab_asignaciones`
|
||||
|
||||
Asignación de enfermero a domicilio. Máximo un enfermero activo por domicilio (`UNIQUE KEY` en `domicilio_id`).
|
||||
|
||||
Campos: `id`, `domicilio_id`, `enfermera_id`, `asignada_por`, `estado` (`asignada` / `confirmada` / `liberada` / `completada`), `notas`.
|
||||
|
||||
#### Tabla `lab_servicios_extra`
|
||||
|
||||
Servicios realizados por el enfermero durante la visita, adicionales a la orden original.
|
||||
|
||||
Tipos disponibles: `inyeccion`, `cura`, `nebulizacion`, `toma_muestra`, `tension_arterial`, `glucometria`, `otro`.
|
||||
|
||||
Campos: `id`, `domicilio_id`, `descripcion`, `tipo`, `notas`, `requiere_pago`, `valor`, `realizado_por`.
|
||||
|
||||
#### Tabla `lab_domicilio_notas`
|
||||
|
||||
Notas registradas por el enfermero durante la visita.
|
||||
|
||||
Dos tipos:
|
||||
- **`clinica`**: datos de la ficha clínica — antecedentes, medicamentos, acudiente (si el paciente es menor de edad).
|
||||
- **`libre`**: nota libre con título, cuerpo de texto enriquecido e imagen adjunta.
|
||||
|
||||
Campos: `id`, `domicilio_id`, `enfermera_id`, `tipo`, `antecedentes`, `medicamentos`, `acudiente_nombre`, `acudiente_documento`, `titulo`, `cuerpo`, `imagen_path`.
|
||||
|
||||
---
|
||||
|
||||
### Roles y permisos
|
||||
|
||||
| Rol | Acceso |
|
||||
|---|---|
|
||||
| **Admin** | Crear, editar y ver todos los domicilios. Asignar/reasignar enfermeros. Registrar pagos. Exportar Excel. Ver informe completo con notas. Ver agenda de cualquier enfermero usando `?eid=X`. |
|
||||
| **Enfermero** | Solo ve su propia agenda (`enfermero_portal.php`). Avanza el estado de sus domicilios asignados. Agrega servicios extra. Registra notas clínicas y libres. Visualiza órdenes médicas adjuntas. |
|
||||
|
||||
> **Redirección automática**: si el usuario autenticado tiene rol `enfermero`, `lab_domicilios.php` lo redirige inmediatamente a `enfermero_portal.php`.
|
||||
|
||||
Los roles se definen en `admin_users.role` (ENUM `admin` / `enfermero`) y `admin_users.enfermera_id` (FK a `lab_enfermeras`).
|
||||
|
||||
---
|
||||
|
||||
### Flujo de estados
|
||||
|
||||
```
|
||||
[programado]
|
||||
│
|
||||
│ El enfermero confirma que realizará la visita
|
||||
▼
|
||||
[confirmado]
|
||||
│
|
||||
│ El enfermero sale hacia el domicilio
|
||||
▼
|
||||
[en_camino]
|
||||
│
|
||||
│ El enfermero llega → hora_llegada se registra automáticamente
|
||||
▼
|
||||
[en_domicilio]
|
||||
│
|
||||
│ El enfermero finaliza → hora_salida se registra automáticamente
|
||||
▼
|
||||
[completado]
|
||||
|
||||
Desde cualquier estado:
|
||||
→ [cancelado] (requiere motivo_cancelacion como campo obligatorio)
|
||||
→ [reprogramado] (requiere fecha_reprogramada)
|
||||
```
|
||||
|
||||
**Transiciones permitidas al enfermero** (validadas en `update_domicilio_enfermero.php`):
|
||||
|
||||
| Estado actual | Estados posibles |
|
||||
|---|---|
|
||||
| `programado` | `confirmado` |
|
||||
| `confirmado` | `en_camino`, `cancelado` |
|
||||
| `en_camino` | `en_domicilio`, `cancelado` |
|
||||
| `en_domicilio` | `completado`, `cancelado` |
|
||||
|
||||
El **admin** puede cambiar a cualquier estado directamente, incluyendo cancelar desde cualquier punto.
|
||||
|
||||
---
|
||||
|
||||
### Funcionalidades
|
||||
|
||||
- **Filtros**: por fecha, estado, enfermero asignado. Botón "Hoy" para filtro rápido.
|
||||
- **Resumen del día**: conteo de domicilios por estado en la parte superior.
|
||||
- **Panel de detalle**: al hacer clic en un domicilio se abre el panel lateral con toda la información, notas clínicas, notas libres e informe imprimible.
|
||||
- **Asignar / Reasignar enfermero**: modal con lista del personal disponible.
|
||||
- **Sin asignar**: badge con el conteo de domicilios que aún no tienen enfermero.
|
||||
- **Registrar pago**: modal para marcar el cobro con modalidad y monto.
|
||||
- **Servicios extra**: el enfermero los agrega desde su portal durante la visita.
|
||||
- **Notas del enfermero**: ficha clínica con antecedentes, medicamentos, acudiente (si menor) y notas libres con imagen adjunta.
|
||||
- **Informe de domicilio**: vista imprimible del domicilio con datos del paciente, ficha clínica y notas del enfermero.
|
||||
- **Exportar CSV (Excel)**: exporta todos los domicilios filtrados, incluyendo las columnas de notas del enfermero (antecedentes, medicamentos, acudiente, notas libres).
|
||||
- **Formularios recibidos**: pestaña dentro de `lab_domicilios.php` que muestra formularios enviados con filtro por plantilla y estado.
|
||||
- **Portal del enfermero**: tarjetas colapsables ordenadas por hora, separadas en "Activos" y "Finalizados". Permite avanzar estados, agregar notas y ver órdenes.
|
||||
|
||||
---
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Método | Descripción |
|
||||
|---|---|---|
|
||||
| `api/lab/get_domicilios.php` | GET | Lista con filtros. `?id=X` para uno solo con detalle completo. |
|
||||
| `api/lab/save_domicilio.php` | POST | Crear, editar o cambiar estado. `?solo_estado=true` para solo cambiar estado. |
|
||||
| `api/lab/update_domicilio_enfermero.php` | POST | El enfermero avanza el estado de su domicilio. |
|
||||
| `api/lab/my_agenda.php` | GET | Agenda del enfermero autenticado con servicios extra. |
|
||||
| `api/lab/save_asignacion.php` | POST | Asignar o reasignar enfermero a domicilio. |
|
||||
| `api/lab/get_asignaciones.php` | GET | Asignaciones por fecha. |
|
||||
| `api/lab/registrar_pago.php` | POST | Registrar pago con monto y modalidad. |
|
||||
| `api/lab/save_servicio_extra.php` | POST | Agregar servicio realizado durante la visita. |
|
||||
| `api/lab/get_notas_domicilio.php` | GET | Notas del domicilio (clínicas y libres). |
|
||||
| `api/lab/upload_nota_imagen.php` | POST | Subir imagen adjunta a una nota libre. |
|
||||
| `api/lab/crear_desde_whatsapp.php` | GET/POST | Crear paciente u orden desde una conversación de WhatsApp activa. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Módulo de Formularios
|
||||
|
||||
### ¿Qué es?
|
||||
|
||||
El módulo de formularios permite crear **plantillas de documentos** (consentimientos, historias clínicas, autorizaciones, encuestas) mediante un builder visual, enviarlas a los pacientes por WhatsApp y recopilar sus respuestas con firma digital. El documento firmado genera un **sello de integridad SHA-256** que puede verificarse públicamente.
|
||||
|
||||
---
|
||||
|
||||
### Archivos involucrados
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---|---|
|
||||
| `lab_formularios.php` | Vista principal — lista de plantillas y registro de envíos |
|
||||
| `lab_formulario_builder.php` | Editor visual drag & drop (ventana separada, solo admin) |
|
||||
| `form_cliente.php` | Página pública — el paciente llena y firma sin iniciar sesión |
|
||||
| `ver_formulario_enviado.php` | Vista del documento firmado — acceso por ID (admin/enfermero) o token (cliente) |
|
||||
| `verificar_formulario.php` | Verificación pública de autenticidad por hash SHA-256 |
|
||||
| `classes/lab/Formulario.php` | Clase ORM — CRUD de plantillas, crear envíos, guardar respuestas, generar hash |
|
||||
| `api/lab/get_formularios.php` | GET — lista plantillas o envíos |
|
||||
| `api/lab/save_formulario.php` | POST — crear, editar y eliminar plantillas (solo admin) |
|
||||
| `api/lab/send_formulario.php` | POST — crear instancia de envío, devolver URL pública y mensaje WhatsApp |
|
||||
| `api/lab/submit_formulario.php` | GET/POST — cargar el formulario por token / guardar la respuesta del cliente |
|
||||
| `api/lab/firmar_profesional.php` | POST — guardar firma del profesional (requiere sesión activa) |
|
||||
|
||||
---
|
||||
|
||||
### Base de datos
|
||||
|
||||
#### Tabla `lab_formularios`
|
||||
|
||||
Plantillas de documentos creadas desde el builder.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---|---|---|
|
||||
| `id` | INT PK | Identificador único |
|
||||
| `nombre` | VARCHAR(150) | Nombre de la plantilla |
|
||||
| `descripcion` | TEXT | Descripción visible al cliente |
|
||||
| `categoria` | ENUM | `consentimiento` / `historia_clinica` / `autorizacion` / `encuesta` / `otro` |
|
||||
| `esquema` | LONGTEXT | JSON con el array de campos del formulario |
|
||||
| `permite_firma` | TINYINT(1) | El formulario tiene sección de firma global |
|
||||
| `requiere_firma` | TINYINT(1) | La firma global es obligatoria |
|
||||
| `firma_modos` | VARCHAR(50) | `canvas`, `foto` o `canvas,foto` (separados por coma) |
|
||||
| `version` | SMALLINT | Se incrementa automáticamente al editar el esquema |
|
||||
| `is_active` | TINYINT(1) | Soft-delete |
|
||||
| `creado_por` | INT FK | Usuario que creó la plantilla |
|
||||
| `doc_encabezado` | VARCHAR | Override del nombre de empresa en el documento |
|
||||
| `doc_subtitulo` | VARCHAR | Override del subtítulo en el documento |
|
||||
| `doc_logo_base64` | LONGTEXT | Override del logo en el documento |
|
||||
| `doc_color` | VARCHAR(20) | Override del color del encabezado del documento |
|
||||
| `doc_pie_pagina` | TEXT | Override del pie de página |
|
||||
|
||||
#### Tabla `lab_form_envios`
|
||||
|
||||
Cada fila es una instancia enviada a un paciente.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---|---|---|
|
||||
| `id` | INT PK | Identificador único |
|
||||
| `formulario_id` | INT FK | Plantilla enviada |
|
||||
| `paciente_id` | INT FK NULL | Paciente asociado |
|
||||
| `domicilio_id` | INT FK NULL | Domicilio asociado (opcional) |
|
||||
| `token` | CHAR(64) UNIQUE | Token público de 64 caracteres hex (acceso sin sesión) |
|
||||
| `datos_prefilled` | LONGTEXT | JSON con datos pre-llenados al enviar (incluye `__paciente.*`) |
|
||||
| `datos_cliente` | LONGTEXT | JSON con las respuestas completadas por el cliente |
|
||||
| `firma_svg` | LONGTEXT | Firma del paciente (PNG base64 — canvas o foto) |
|
||||
| `ip_cliente` | VARCHAR(45) | IP del cliente al enviar el formulario |
|
||||
| `user_agent` | VARCHAR(512) | Navegador del cliente |
|
||||
| `estado` | ENUM | `pendiente` / `completado` / `firmado` / `expirado` |
|
||||
| `enviado_por` | INT FK | Usuario que generó el enlace |
|
||||
| `enviado_via` | ENUM | `whatsapp` / `email` / `link` |
|
||||
| `expira_en` | DATETIME NULL | Siempre `NULL` — el enlace no expira |
|
||||
| `completado_en` | DATETIME | Fecha y hora en que el cliente completó el formulario |
|
||||
| `hash_verificacion` | CHAR(64) | Sello de integridad SHA-256 del documento |
|
||||
|
||||
---
|
||||
|
||||
### Roles y permisos
|
||||
|
||||
| Rol | Acceso |
|
||||
|---|---|
|
||||
| **Admin** | Crear, editar y eliminar plantillas desde el builder. Enviar formularios a cualquier paciente. Ver todos los envíos. Ver y descargar el PDF de cualquier formulario. |
|
||||
| **Enfermero** | Enviar formularios existentes a sus pacientes. Ver solo sus propios envíos (`enviado_por = su user_id`). No puede crear ni editar plantillas. Puede firmar como profesional en los formularios que él mismo envió. |
|
||||
| **Cliente (público)** | Accede a `form_cliente.php?t=TOKEN` sin ninguna autenticación. Llena y firma el formulario. Puede volver al mismo enlace en cualquier momento para ver el documento firmado y descargarlo como PDF. |
|
||||
|
||||
---
|
||||
|
||||
### Flujo completo
|
||||
|
||||
```
|
||||
1. ADMIN crea la plantilla
|
||||
├─ Abre lab_formulario_builder.php (se abre en ventana nueva)
|
||||
├─ Arrastra campos al canvas y los configura
|
||||
├─ Configura el diseño del documento (logo, color, encabezado, pie de página)
|
||||
└─ Guarda → POST api/lab/save_formulario.php → lab_formularios
|
||||
|
||||
2. ADMIN o ENFERMERO envía el formulario
|
||||
├─ lab_formularios.php → botón "Enviar" → modal
|
||||
├─ Busca y selecciona el paciente
|
||||
├─ Previsualiza los campos que llegarán pre-llenados
|
||||
├─ Selecciona el canal: WhatsApp o "solo link"
|
||||
└─ POST api/lab/send_formulario.php
|
||||
├─ Genera token de 64 hex chars: bin2hex(random_bytes(32))
|
||||
├─ Crea fila en lab_form_envios (estado=pendiente, expira_en=NULL)
|
||||
└─ Devuelve URL pública y mensaje preformateado para WhatsApp
|
||||
|
||||
3. CLIENTE recibe el enlace (por WhatsApp u otro medio)
|
||||
├─ Abre form_cliente.php?t=TOKEN
|
||||
├─ GET api/lab/submit_formulario.php?t=TOKEN → carga datos del formulario
|
||||
└─ Si ya fue firmado antes → muestra pantalla de solo lectura con link al PDF
|
||||
|
||||
4. CLIENTE llena el formulario
|
||||
├─ Campos "linked" llegan pre-llenados con datos del paciente (readonly si tienen valor)
|
||||
├─ Campos vacíos linked son editables para que el cliente los complete
|
||||
├─ Campos firma_profesional muestran aviso "uso exclusivo del profesional"
|
||||
└─ Dibuja su firma (canvas) o adjunta una foto de firma
|
||||
|
||||
5. CLIENTE envía
|
||||
├─ POST api/lab/submit_formulario.php
|
||||
├─ Se genera hash SHA-256 (contenido + firma + ID + token + timestamp)
|
||||
├─ Estado → "firmado" (si hay firma) o "completado" (sin firma)
|
||||
└─ Pantalla de éxito con hash visible y botón para descargar el PDF
|
||||
|
||||
6. PROFESIONAL firma (si el formulario lo requiere)
|
||||
├─ Admin/Enfermero abre ver_formulario_enviado.php?id=X con sesión activa
|
||||
├─ Aparece canvas de firma en la posición del campo firma_profesional
|
||||
├─ Dibuja su firma y hace clic en "Guardar firma"
|
||||
└─ POST api/lab/firmar_profesional.php → guarda campo_id_svg en datos_cliente
|
||||
|
||||
7. ADMIN/ENFERMERO revisa el resultado
|
||||
├─ lab_formularios.php → pestaña "Envíos" → icono "Ver respuesta"
|
||||
└─ ver_formulario_enviado.php?id=X → documento HTML imprimible
|
||||
|
||||
8. VERIFICACIÓN pública de integridad
|
||||
└─ verificar_formulario.php?h=HASH_SHA256
|
||||
├─ Busca en lab_form_envios.hash_verificacion
|
||||
└─ Muestra: nombre del formulario, paciente, fecha, estado y si el sello es válido
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Tipos de campos del Builder
|
||||
|
||||
#### Campos de entrada
|
||||
|
||||
| Tipo | Descripción |
|
||||
|---|---|
|
||||
| `texto` | Campo de texto corto de una sola línea |
|
||||
| `textarea` | Área de texto largo (varias líneas) |
|
||||
| `numero` | Campo numérico |
|
||||
| `fecha` | Selector de fecha |
|
||||
| `hora` | Selector de hora |
|
||||
| `select` | Lista desplegable con opciones configurables |
|
||||
| `radio` | Selección única con opciones configurables |
|
||||
| `checkbox` | Selección múltiple con opciones configurables |
|
||||
| `lista_marcable` | Lista de ítems numerados con checkboxes |
|
||||
|
||||
#### Campos de firma
|
||||
|
||||
| Tipo | Descripción |
|
||||
|---|---|
|
||||
| `firma` | Firma del **paciente** — visible y editable en `form_cliente.php` |
|
||||
| `firma_profesional` | Firma del **profesional** — bloqueada para el cliente; solo editable desde el panel admin/enfermero |
|
||||
|
||||
#### Campos de contenido
|
||||
|
||||
| Tipo | Descripción |
|
||||
|---|---|
|
||||
| `separador` | Separador visual o título de sección |
|
||||
| `parrafo` | Bloque de texto estático (pre-formatado o flujo libre) |
|
||||
| `parrafo_inline` | Párrafo con marcadores `{nombre_completo}`, `{telefono}`, etc. que se convierten en espacios editables si el valor está vacío |
|
||||
|
||||
#### Campos vinculados al paciente (`tipo: linked`)
|
||||
|
||||
Se auto-rellenan con los datos del paciente al momento de enviar. Si el valor existe → campo de solo lectura. Si está vacío → el cliente puede completarlo.
|
||||
|
||||
| `linked_key` | Dato que extrae |
|
||||
|---|---|
|
||||
| `nombre_completo` | Nombre completo del paciente |
|
||||
| `numero_documento` | Número de documento |
|
||||
| `tipo_documento` | Tipo de documento |
|
||||
| `fecha_nacimiento` | Fecha de nacimiento |
|
||||
| `telefono` | Teléfono |
|
||||
| `email` | Correo electrónico |
|
||||
| `eps` | EPS o aseguradora |
|
||||
| `direccion` | Dirección |
|
||||
|
||||
---
|
||||
|
||||
### Firma digital
|
||||
|
||||
**Modos disponibles** (configurados en la plantilla mediante `firma_modos`):
|
||||
|
||||
| Modo | Funcionamiento |
|
||||
|---|---|
|
||||
| `canvas` | El cliente dibuja su firma con el dedo o el mouse. Se captura con `canvas.toDataURL('image/png')`. |
|
||||
| `foto` | El cliente sube una imagen desde su cámara o galería (`<input accept="image/*" capture="environment">`). Se convierte a base64 con `FileReader`. |
|
||||
|
||||
Ambos modos pueden estar activos simultáneamente en la misma plantilla.
|
||||
|
||||
**Firma global vs. firma por campo:**
|
||||
- Si el esquema **no incluye** campos tipo `firma`, se muestra una sección de firma global al pie del formulario.
|
||||
- Si el esquema **incluye** campos `firma`, cada uno tiene su propio widget canvas independiente en la posición configurada dentro del formulario.
|
||||
|
||||
---
|
||||
|
||||
### Enlace público y vigencia
|
||||
|
||||
- **URL pública**: `form_cliente.php?t=TOKEN`
|
||||
- **TOKEN**: 64 caracteres hexadecimales generados con `bin2hex(random_bytes(32))`.
|
||||
- **Sin sesión**: el cliente no necesita crear cuenta ni iniciar sesión.
|
||||
- **Sin vencimiento**: la columna `expira_en` existe en la tabla pero siempre es `NULL`. El enlace es permanente.
|
||||
- **Bloqueo por estado**: si el formulario ya fue completado o firmado, el enlace muestra la pantalla de solo lectura. No permite modificar la respuesta.
|
||||
- **Idempotencia**: si el cliente reintenta enviar (por error de red, por ejemplo), el sistema devuelve éxito con los datos ya guardados en lugar de crear un duplicado.
|
||||
|
||||
---
|
||||
|
||||
### PDF y visualización del documento
|
||||
|
||||
No se usa ninguna librería de generación de PDF en el backend. El documento es la página `ver_formulario_enviado.php` con estilos `@media print`. El usuario puede imprimirla o guardarla como PDF directamente desde el navegador.
|
||||
|
||||
**Contenido del documento impreso:**
|
||||
- Encabezado con logo, nombre, subtítulo, datos de contacto y color corporativo
|
||||
- Datos del paciente (nombre, documento, fecha de nacimiento, teléfono, EPS)
|
||||
- Respuestas del formulario campo por campo, en el orden del esquema
|
||||
- Imagen de la firma del paciente
|
||||
- Firma del profesional (si fue completada)
|
||||
- Sello SHA-256 con link para verificar autenticidad
|
||||
- Pie de página con fecha de generación e ID del documento
|
||||
|
||||
**Formas de acceder al documento:**
|
||||
|
||||
| URL | Quién puede acceder |
|
||||
|---|---|
|
||||
| `ver_formulario_enviado.php?id=X` | Admin (cualquier formulario) o Enfermero (solo los que él envió). Requiere sesión. |
|
||||
| `ver_formulario_enviado.php?t=TOKEN` | Cliente u cualquier persona con el enlace. Sin sesión. Solo si el estado es `firmado` o `completado`. |
|
||||
|
||||
---
|
||||
|
||||
### Sello de integridad SHA-256
|
||||
|
||||
Al guardar la respuesta del cliente, el sistema genera un hash SHA-256 que vincula de forma única el contenido del formulario con la firma y el momento en que se completó.
|
||||
|
||||
**Construcción del hash** (en `Formulario::guardarRespuesta()`):
|
||||
|
||||
```
|
||||
SHA-256 de:
|
||||
JSON de los datos del cliente
|
||||
+ firma SVG/PNG del paciente
|
||||
+ ID interno del envío
|
||||
+ token del enlace
|
||||
+ timestamp del momento de registro
|
||||
```
|
||||
|
||||
**¿Para qué sirve?** Cualquier persona con el hash puede ir a `verificar_formulario.php?h=HASH` para confirmar que:
|
||||
- El documento existe en la base de datos.
|
||||
- El nombre del formulario y del paciente.
|
||||
- La fecha en que fue completado.
|
||||
- El estado actual (firmado / completado).
|
||||
|
||||
Si el documento fue alterado, el hash no coincidirá y la verificación fallará.
|
||||
|
||||
---
|
||||
|
||||
### Firma del profesional
|
||||
|
||||
Algunos formularios requieren que un profesional de salud también firme el documento, además del paciente.
|
||||
|
||||
**Flujo:**
|
||||
1. Al diseñar la plantilla en el builder se agrega un campo `tipo: firma_profesional` en la posición deseada.
|
||||
2. Cuando el cliente llena el formulario en `form_cliente.php`, ese campo muestra solo un aviso: *"Uso exclusivo del profesional de salud"*. El cliente no puede interactuar con él.
|
||||
3. Una vez que el cliente ha completado y enviado el formulario, el admin o enfermero abre `ver_formulario_enviado.php?id=X` con sesión activa y verá el canvas de firma en esa posición.
|
||||
4. El profesional dibuja su firma y hace clic en "Guardar firma".
|
||||
5. La firma se guarda mediante POST a `api/lab/firmar_profesional.php`.
|
||||
|
||||
**Validaciones en el servidor:**
|
||||
- Requiere sesión activa (`isUserLoggedIn()`).
|
||||
- Si el usuario es enfermero, solo puede firmar en formularios que él mismo envió.
|
||||
- Valida que el `campo_id` corresponde a un campo `tipo: firma_profesional` en el esquema del formulario.
|
||||
- Valida que la imagen enviada sea un data URI de imagen válido.
|
||||
|
||||
> Si el formulario se accede via `?t=TOKEN` (cliente público), el canvas **no aparece**. En su lugar se muestra un aviso *"Pendiente de firma del profesional"* (solo visible en pantalla, no en el PDF impreso).
|
||||
|
||||
---
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Método | Autenticación | Descripción |
|
||||
|---|---|---|---|
|
||||
| `api/lab/get_formularios.php` | GET | Sesión | Lista plantillas. `?id=X` para una sola. `?envios=1` para lista de envíos. |
|
||||
| `api/lab/save_formulario.php` | POST | Admin | Crear, editar o eliminar una plantilla. |
|
||||
| `api/lab/send_formulario.php` | POST | Admin / Enfermero | Crear instancia de envío. Devuelve URL pública y mensaje para WhatsApp. |
|
||||
| `api/lab/submit_formulario.php` | GET | Público | Cargar el formulario por token (sin sesión). |
|
||||
| `api/lab/submit_formulario.php` | POST | Público | Guardar la respuesta y firma del cliente. |
|
||||
| `api/lab/firmar_profesional.php` | POST | Sesión | Guardar la firma del profesional en un campo `firma_profesional`. |
|
||||
|
||||
---
|
||||
|
||||
*Documentación generada para uso interno del equipo.*
|
||||
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
|
||||
|
||||
@@ -23,6 +23,21 @@ que solo sirve para ordenar:
|
||||
El título sale del primer encabezado `#` del archivo. No hay que registrar nada
|
||||
en ningún índice: se descubre solo.
|
||||
|
||||
## Limitar un documento a ciertos roles
|
||||
|
||||
Por defecto un documento hereda el permiso de su sección. Para restringirlo más,
|
||||
se declara al inicio del archivo:
|
||||
|
||||
---
|
||||
roles: enfermero, supervisor
|
||||
---
|
||||
|
||||
# Enfermeros — domicilios
|
||||
|
||||
Solo esos roles lo ven; para el resto no aparece en el índice ni es accesible
|
||||
por URL. Los administradores ven todo, siempre. Una sección que queda sin
|
||||
documentos visibles no se muestra.
|
||||
|
||||
## Contenido que se genera solo
|
||||
|
||||
Estos marcadores, en una línea propia, se reemplazan al cargar la página con
|
||||
|
||||
+5
-125
@@ -1,129 +1,9 @@
|
||||
# Módulo Administrativo — Laboratorio Clínico
|
||||
# Obsoleto
|
||||
|
||||
Módulo add-on para el sistema de chatbot WhatsApp que permite gestionar órdenes médicas recibidas como imágenes, domicilios, enfermeras y pacientes.
|
||||
Este documento quedó desactualizado y se conserva solo por historial de git.
|
||||
|
||||
---
|
||||
La documentación vigente está **dentro del sistema**, en el módulo Soporte:
|
||||
|
||||
## Instalación
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
|
||||
### 1. Ejecutar migraciones de base de datos
|
||||
|
||||
```bash
|
||||
php migrations/20260302_lab_run_migrations.php
|
||||
```
|
||||
|
||||
Crea 7 tablas nuevas sin modificar las existentes:
|
||||
- `lab_pacientes`
|
||||
- `lab_enfermeras`
|
||||
- `lab_ordenes_medicas`
|
||||
- `lab_domicilios`
|
||||
- `lab_asignaciones`
|
||||
- `lab_autorizaciones`
|
||||
- `lab_actividad_admin`
|
||||
|
||||
Para deshacer:
|
||||
```bash
|
||||
php migrations/20260302_lab_run_migrations.php --rollback
|
||||
```
|
||||
|
||||
### 2. Verificar instalación
|
||||
|
||||
Accede desde el navegador (con sesión admin activa):
|
||||
```
|
||||
https://tu-servidor/lab_status.php
|
||||
```
|
||||
|
||||
O desde CLI:
|
||||
```bash
|
||||
php lab_status.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archivos del módulo
|
||||
|
||||
### Vistas PHP
|
||||
| Archivo | Descripción |
|
||||
|---|---|
|
||||
| `lab_dashboard.php` | Panel principal con estadísticas en tiempo real |
|
||||
| `lab_ordenes.php` | Gestión de órdenes médicas (estados, imágenes, historial) |
|
||||
| `lab_pacientes.php` | CRUD de pacientes, vinculación con usuarios WhatsApp |
|
||||
| `lab_domicilios.php` | Agenda de domicilios y asignación de enfermeras |
|
||||
| `lab_enfermeras.php` | CRUD de enfermeras y visualización de agenda diaria |
|
||||
| `lab_reportes.php` | Trazabilidad, log de actividad, exportación CSV |
|
||||
| `lab_status.php` | Verificador de estado del módulo |
|
||||
|
||||
### Clases (models)
|
||||
Ubicadas en `classes/lab/`:
|
||||
- `ActividadAdmin.php` — Base de trazabilidad
|
||||
- `Paciente.php` — Modelo de pacientes
|
||||
- `Enfermera.php` — Modelo de enfermeras
|
||||
- `OrdenMedica.php` — Modelo de órdenes médicas con flujo de estados
|
||||
- `Domicilio.php` — Modelo de domicilios con flujo de estados
|
||||
- `Asignacion.php` — Modelo de asignaciones enfermera ↔ domicilio
|
||||
|
||||
### API REST
|
||||
Ubicados en `api/lab/`:
|
||||
| Endpoint | Método | Descripción |
|
||||
|---|---|---|
|
||||
| `get_pacientes.php` | GET | Lista paginada de pacientes |
|
||||
| `save_paciente.php` | POST | Crear/actualizar paciente |
|
||||
| `get_ordenes.php` | GET | Lista/detalle de órdenes |
|
||||
| `save_orden.php` | POST | Crear/actualizar orden |
|
||||
| `autorizar_orden.php` | POST | Cambiar estado de una orden |
|
||||
| `get_domicilios.php` | GET | Lista/detalle de domicilios |
|
||||
| `save_domicilio.php` | POST | Crear/actualizar domicilio |
|
||||
| `get_enfermeras.php` | GET | Lista de enfermeras + agenda |
|
||||
| `save_enfermera.php` | POST | Crear/actualizar enfermera |
|
||||
| `get_asignaciones.php` | GET | Asignaciones por fecha |
|
||||
| `save_asignacion.php` | POST | Asignar/liberar/completar enfermera |
|
||||
| `get_actividad.php` | GET | Log de actividad con filtros |
|
||||
| `get_stats.php` | GET | Estadísticas para dashboard |
|
||||
| `crear_desde_whatsapp.php` | GET/POST | Crear orden desde conversación activa |
|
||||
|
||||
---
|
||||
|
||||
## Flujos de estado
|
||||
|
||||
### Órdenes médicas
|
||||
```
|
||||
pendiente → en_revision → autorizada → en_domicilio → completada
|
||||
↘ rechazada
|
||||
```
|
||||
|
||||
### Domicilios
|
||||
```
|
||||
programado → confirmado → en_camino → en_domicilio → completado
|
||||
↘ cancelado
|
||||
↘ reprogramado
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integración con el chatbot
|
||||
|
||||
En `conversations.php`, los mensajes de imagen entrantes tienen un botón **<i class="fas fa-flask"></i>** (verde) en las acciones del mensaje. Al hacer click:
|
||||
|
||||
1. Se abre un modal con la imagen adjunta
|
||||
2. El operador busca o selecciona un paciente (o usa el contacto de la conversación)
|
||||
3. Completa datos opcionales (médico, exámenes, ayuno)
|
||||
4. Se crea la orden en estado `pendiente`
|
||||
|
||||
---
|
||||
|
||||
## Exportaciones CSV
|
||||
|
||||
Disponibles desde `lab_reportes.php`:
|
||||
- **Órdenes médicas** del período — incluye estado, médico, exámenes
|
||||
- **Domicilios** del período — incluye enfermera asignada, dirección, estado
|
||||
- **Pacientes** — catálogo completo con total de órdenes
|
||||
|
||||
---
|
||||
|
||||
## Requisitos
|
||||
|
||||
- PHP 8.2+
|
||||
- MariaDB 10.11+ (o MySQL 8+)
|
||||
- Bootstrap 5.3 (ya incluido en el sistema)
|
||||
- Font Awesome 6.4 (ya incluido en el sistema)
|
||||
- `uploads/media/` con permisos de escritura (755/775)
|
||||
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
# Webhook WhatsApp — Endpoints y Características
|
||||
|
||||
## Endpoint principal
|
||||
|
||||
```
|
||||
URL: /api/webhook.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET — Verificación de webhook
|
||||
|
||||
```
|
||||
GET /api/webhook.php?hub.mode=subscribe&hub.verify_token=TOKEN&hub.challenge=CHALLENGE
|
||||
```
|
||||
|
||||
### Parámetros que envía Meta
|
||||
| Parámetro | Valor esperado |
|
||||
|---|---|
|
||||
| `hub.mode` | `subscribe` |
|
||||
| `hub.verify_token` | El token configurado en `system_config.webhook_verify_token` |
|
||||
| `hub.challenge` | Número aleatorio que debe devolverse tal cual |
|
||||
|
||||
### ⚠️ Bug conocido
|
||||
El código lee `$_GET['hub_verify_token']` (con guión bajo), pero PHP convierte los puntos a guiones bajos automáticamente al parsear `$_GET`, por lo que **funciona correctamente**.
|
||||
|
||||
### Respuesta exitosa
|
||||
```
|
||||
HTTP 200
|
||||
Body: {challenge}
|
||||
```
|
||||
|
||||
### Respuesta fallida
|
||||
```
|
||||
HTTP 403
|
||||
Body: {"error":"Token de verificación inválido"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST — Recepción de eventos
|
||||
|
||||
```
|
||||
POST /api/webhook.php
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Estructura del payload esperado (Meta Cloud API)
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"id": "WABA_ID",
|
||||
"changes": [{
|
||||
"field": "messages",
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {
|
||||
"phone_number_id": "PHONE_NUMBER_ID"
|
||||
},
|
||||
"contacts": [{
|
||||
"wa_id": "573001234567",
|
||||
"profile": { "name": "Nombre Contacto" }
|
||||
}],
|
||||
"messages": [{
|
||||
"from": "573001234567",
|
||||
"id": "wamid.XXX",
|
||||
"timestamp": "1234567890",
|
||||
"type": "text",
|
||||
"text": { "body": "Hola" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Tipos de mensaje soportados
|
||||
| `type` | Descripción |
|
||||
|---|---|
|
||||
| `text` | Texto plano |
|
||||
| `image` | Imagen (con caption opcional) |
|
||||
| `audio` | Audio / nota de voz |
|
||||
| `video` | Video |
|
||||
| `document` | Documento / PDF |
|
||||
| `sticker` | Sticker |
|
||||
| `reaction` | Reacción emoji a otro mensaje |
|
||||
| `interactive` | Respuesta de lista o botón |
|
||||
|
||||
### El campo `field` del change puede ser
|
||||
- `messages` → mensajes entrantes y estados
|
||||
- `conversations` → alias aceptado también
|
||||
|
||||
### Eventos de estado (statuses)
|
||||
```json
|
||||
"statuses": [{
|
||||
"id": "wamid.XXX",
|
||||
"status": "sent|delivered|read|failed",
|
||||
"recipient_id": "573001234567"
|
||||
}]
|
||||
```
|
||||
|
||||
### Respuesta exitosa
|
||||
```
|
||||
HTTP 200
|
||||
Body: {"status":"success"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuración necesaria en `system_config` (BD)
|
||||
|
||||
| config_key | Descripción |
|
||||
|---|---|
|
||||
| `whatsapp_token` | Access Token de Meta |
|
||||
| `whatsapp_phone_number_id` | Phone Number ID de la línea |
|
||||
| `webhook_verify_token` | Token de verificación del webhook |
|
||||
| `whatsapp_api_url` | `https://graph.facebook.com/v22.0/` |
|
||||
|
||||
---
|
||||
|
||||
## Variables de entorno equivalentes (`.env`)
|
||||
|
||||
```env
|
||||
WHATSAPP_TOKEN=
|
||||
WHATSAPP_PHONE_NUMBER_ID=
|
||||
WEBHOOK_VERIFY_TOKEN=
|
||||
WHATSAPP_API_URL=https://graph.facebook.com/v22.0/
|
||||
DB_HOST=
|
||||
DB_PORT=3306
|
||||
DB_NAME=
|
||||
DB_USER=
|
||||
DB_PASS=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tablas BD que usa el webhook
|
||||
|
||||
| Tabla | Uso |
|
||||
|---|---|
|
||||
| `users` | Crea o busca usuario por `phone_number` |
|
||||
| `conversations` | Guarda cada mensaje (deduplicado por `message_id`) |
|
||||
| `webhook_logs` | Registra el payload crudo de cada POST |
|
||||
| `notifications` | Crea aviso de nuevo mensaje entrante |
|
||||
| `media_queue` | Encola media que no pudo descargarse en el momento |
|
||||
| `system_config` | Lee tokens y configuración |
|
||||
|
||||
---
|
||||
|
||||
## Seguridad — pendiente de implementar
|
||||
|
||||
- No valida la firma `X-Hub-Signature-256` en el POST.
|
||||
- Se recomienda agregar antes de procesar:
|
||||
```php
|
||||
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
|
||||
$expected = 'sha256=' . hash_hmac('sha256', $input, APP_SECRET);
|
||||
if (!hash_equals($expected, $signature)) {
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
```
|
||||
@@ -21,6 +21,53 @@ requireAuthentication();
|
||||
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
||||
$db = Database::getInstance();
|
||||
|
||||
/**
|
||||
* Le pide el teléfono a quien lo tiene oculto en WhatsApp, una sola vez.
|
||||
*
|
||||
* Solo aplica a quien se identifica con un BSUID: de esa persona no tenemos
|
||||
* número, y sin él el laboratorio no puede llamarla. Se le manda el botón que
|
||||
* Meta dispone para esto y ella decide si lo comparte; si acepta, el webhook
|
||||
* recibe el teléfono y lo vincula solo.
|
||||
*
|
||||
* No se insiste: pedir los datos una vez es razonable, repetirlo en cada
|
||||
* trámite es acoso. Tampoco se interrumpe la creación de la ficha si el envío
|
||||
* falla, porque la ficha es lo importante.
|
||||
*
|
||||
* @return string qué pasó, para que la interfaz lo pueda mostrar
|
||||
*/
|
||||
function pedirContactoSiHaceFalta(Database $db, int $userId): string {
|
||||
$u = $db->fetch(
|
||||
'SELECT phone_number, contacto_pedido_at FROM users WHERE id = ?',
|
||||
[$userId]
|
||||
);
|
||||
if (!$u) return 'usuario_no_encontrado';
|
||||
if (!esBsuid($u['phone_number'])) return 'no_hace_falta'; // ya tenemos su número
|
||||
if (!empty($u['contacto_pedido_at'])) return 'ya_se_pidio';
|
||||
|
||||
try {
|
||||
require_once __DIR__ . '/../../services/WhatsAppService.php';
|
||||
$wa = new WhatsAppService();
|
||||
$texto = getConfigFromDB(
|
||||
'whatsapp_texto_pedir_contacto',
|
||||
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?'
|
||||
);
|
||||
|
||||
$r = $wa->pedirContacto($u['phone_number'], $texto);
|
||||
if (!$r) {
|
||||
error_log('[crear_desde_whatsapp] WhatsApp rechazó la solicitud de contacto del usuario ' . $userId);
|
||||
return 'fallo_envio';
|
||||
}
|
||||
|
||||
// Se marca solo si Meta aceptó: si falló, hay que poder reintentarlo
|
||||
$db->update('users', ['contacto_pedido_at' => date('Y-m-d H:i:s')], 'id = ?', [$userId]);
|
||||
return 'pedido';
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('[crear_desde_whatsapp] Error pidiendo el contacto: ' . $e->getMessage());
|
||||
return 'fallo_envio';
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET: solo_paciente ─────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$pacienteRepo = new Paciente();
|
||||
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
||||
$paciente = $pacienteRepo->obtener($pacienteId);
|
||||
echo json_encode(['success' => true, 'paciente' => $paciente]);
|
||||
|
||||
// Si la persona oculta su teléfono, la ficha queda sin número. Es el momento
|
||||
// de pedírselo: se le manda el botón de WhatsApp una sola vez.
|
||||
$contactoPedido = pedirContactoSiHaceFalta($db, (int) $conv['user_id']);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'paciente' => $paciente,
|
||||
'contacto_pedido' => $contactoPedido,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/lab/get_tv_log.php — Bitácora de la voz del televisor.
|
||||
* Para la pantalla de Actividad: resumen de salud + últimos fallos, en llano.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// Resumen de los últimos 7 días por evento
|
||||
$resumen = $pdo->query(
|
||||
"SELECT evento, COUNT(*) n FROM turnero_tv_log
|
||||
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
GROUP BY evento"
|
||||
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
// Últimos incidentes (lo normal —start/end— no se lista: solo lo que falló)
|
||||
$stmt = $pdo->query(
|
||||
"SELECT evento, detalle, created_at FROM turnero_tv_log
|
||||
WHERE evento IN ('mudo', 'sin_start', 'error', 'lag')
|
||||
ORDER BY id DESC LIMIT 30"
|
||||
);
|
||||
$incidentes = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$d = json_decode($r['detalle'] ?? '{}', true) ?: [];
|
||||
$incidentes[] = [
|
||||
'cuando' => $r['created_at'],
|
||||
'evento' => $r['evento'],
|
||||
'turno' => $d['cod'] ?? null,
|
||||
'voz' => $d['voz'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$anuncios = (int)($resumen['start'] ?? 0);
|
||||
$mudos = (int)($resumen['mudo'] ?? 0);
|
||||
$avisos = (int)($resumen['sin_start'] ?? 0) + (int)($resumen['error'] ?? 0);
|
||||
|
||||
jsonOk([
|
||||
'anuncios_7d' => $anuncios,
|
||||
'completos_7d' => (int)($resumen['end'] ?? 0),
|
||||
'mudos_7d' => $mudos,
|
||||
'avisos_7d' => $avisos,
|
||||
'incidentes' => $incidentes,
|
||||
]);
|
||||
+112
-6
@@ -113,24 +113,41 @@ class WhatsAppWebhook {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extraer nombres de contactos del payload (contacts[].profile.name)
|
||||
// Extraer nombres de contactos del payload (contacts[].profile.name).
|
||||
// Se indexa por wa_id y también por user_id (BSUID): desde que Meta desplegó
|
||||
// los nombres de usuario, quien oculta su teléfono llega sin wa_id.
|
||||
$contactNames = [];
|
||||
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
||||
foreach ($value['contacts'] as $contact) {
|
||||
$waId = $contact['wa_id'] ?? null;
|
||||
$name = $contact['profile']['name'] ?? null;
|
||||
if ($waId && $name) {
|
||||
$contactNames[$waId] = $name;
|
||||
if (!$name) continue;
|
||||
foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
|
||||
if ($clave) $contactNames[$clave] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($items as $message) {
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos.
|
||||
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
|
||||
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
|
||||
$timestamp = $message['timestamp'] ?? null;
|
||||
|
||||
// Meta manda el BSUID en todos los mensajes, traigan teléfono o no.
|
||||
$bsuid = $message['from_user_id'] ?? null;
|
||||
|
||||
// Si la persona oculta su teléfono, `from` y `wa_id` no llegan. En ese caso
|
||||
// se busca por BSUID: si ya escribió antes mostrando su número, se le
|
||||
// reconoce y se le sigue respondiendo a ese teléfono.
|
||||
if (empty($phoneNumber) && $bsuid) {
|
||||
$conocido = $this->db->fetch(
|
||||
"SELECT phone_number FROM users WHERE bsuid = :b", ['b' => $bsuid]
|
||||
);
|
||||
// Si no se le conoce, el BSUID hace de identificador: sirve para responderle,
|
||||
// aunque no permita cruzarlo con el paciente ni con el turnero.
|
||||
$phoneNumber = $conocido['phone_number'] ?? $bsuid;
|
||||
}
|
||||
|
||||
// Si falta lo crítico, saltar
|
||||
if (empty($phoneNumber) || empty($messageId)) {
|
||||
continue;
|
||||
@@ -148,7 +165,7 @@ class WhatsAppWebhook {
|
||||
|
||||
// Obtener o crear usuario
|
||||
$user = $this->getUserByPhone($phoneNumber);
|
||||
$contactName = $contactNames[$phoneNumber] ?? null;
|
||||
$contactName = $contactNames[$phoneNumber] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : null);
|
||||
if (!$user) {
|
||||
$userId = $this->createUser($phoneNumber);
|
||||
$user = $this->getUserById($userId);
|
||||
@@ -173,6 +190,20 @@ class WhatsAppWebhook {
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar la equivalencia BSUID↔usuario mientras la persona todavía muestra
|
||||
// su teléfono. El día que lo oculte, ese registro es lo único que permitirá
|
||||
// reconocerla, así que se anota en cada mensaje y no solo la primera vez.
|
||||
if ($bsuid && !empty($user['id']) && ($user['bsuid'] ?? null) !== $bsuid) {
|
||||
try {
|
||||
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$user['id']]);
|
||||
$user['bsuid'] = $bsuid;
|
||||
} catch (Exception $e) {
|
||||
// Choca si ese BSUID ya está en otro usuario (la persona cambió de
|
||||
// número). No es motivo para perder el mensaje: se sigue adelante.
|
||||
error_log('[webhook] No se pudo guardar el BSUID ' . $bsuid . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Procesar diferentes tipos de mensaje
|
||||
$messageText = '';
|
||||
$messageType = 'text';
|
||||
@@ -191,6 +222,28 @@ class WhatsAppWebhook {
|
||||
'reaction_emoji' => $emoji
|
||||
];
|
||||
|
||||
} elseif (($message['type'] ?? '') === 'contacts') {
|
||||
// La persona compartió su contacto, sea por el botón que se le pidió
|
||||
// o a mano. Es la única forma de obtener el teléfono de quien lo oculta.
|
||||
$messageType = 'contacts';
|
||||
$messageText = json_encode($message['contacts'] ?? []);
|
||||
|
||||
$telefonoCompartido = null;
|
||||
foreach ($message['contacts'] ?? [] as $c) {
|
||||
foreach ($c['phones'] ?? [] as $t) {
|
||||
// wa_id ya viene normalizado; `phone` puede traer espacios y signos
|
||||
$candidato = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||
if ($candidato) {
|
||||
$telefonoCompartido = preg_replace('/[^0-9]/', '', $candidato);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($telefonoCompartido && !empty($user['id'])) {
|
||||
$this->vincularTelefonoCompartido($user, $telefonoCompartido, $bsuid);
|
||||
}
|
||||
|
||||
} elseif (isset($message['interactive'])) {
|
||||
// Interactive replies (list or button) - normalize to text so bot can process
|
||||
$messageType = 'text';
|
||||
@@ -450,6 +503,59 @@ class WhatsAppWebhook {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vincula el teléfono que la persona acaba de compartir con el usuario que
|
||||
* hasta ahora solo se conocía por su BSUID.
|
||||
*
|
||||
* Puede haber dos registros de la misma persona: el viejo, de cuando escribía
|
||||
* mostrando el número, y el nuevo creado con el BSUID de identificador. No se
|
||||
* fusionan aquí (implicaría mover conversaciones, estados y aceptación de
|
||||
* términos, y una fusión mal hecha mezcla historias clínicas de dos personas):
|
||||
* se deja el registro con el teléfono como el bueno y se marca el otro, para
|
||||
* que alguien lo revise.
|
||||
*
|
||||
* @return bool si el teléfono quedó vinculado
|
||||
*/
|
||||
private function vincularTelefonoCompartido(&$user, $telefono, $bsuid) {
|
||||
// Ya lo teníamos: nada que hacer
|
||||
if (($user['phone_number'] ?? null) === $telefono) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$existente = $this->db->fetch(
|
||||
"SELECT id FROM users WHERE phone_number = :t AND id <> :id",
|
||||
['t' => $telefono, 'id' => $user['id']]
|
||||
);
|
||||
|
||||
try {
|
||||
if ($existente) {
|
||||
// El registro bueno es el que tiene el teléfono. Se le pasa el BSUID
|
||||
// para que a partir de ahora se le reconozca por ahí.
|
||||
if ($bsuid) {
|
||||
$this->db->update('users', ['bsuid' => null], 'id = ?', [$user['id']]);
|
||||
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$existente['id']]);
|
||||
}
|
||||
error_log(sprintf(
|
||||
'[webhook] BSUID %s compartió el teléfono %s, que ya era del usuario %d. ' .
|
||||
'El usuario %d queda duplicado y hay que revisarlo a mano.',
|
||||
$bsuid, $telefono, $existente['id'], $user['id']
|
||||
));
|
||||
$user = $this->getUserById($existente['id']) ?: $user;
|
||||
return true;
|
||||
}
|
||||
|
||||
// No había otro registro: el placeholder pasa a tener el teléfono real
|
||||
$this->db->update('users', ['phone_number' => $telefono], 'id = ?', [$user['id']]);
|
||||
$user['phone_number'] = $telefono;
|
||||
error_log(sprintf('[webhook] BSUID %s quedó vinculado al teléfono %s', $bsuid, $telefono));
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('[webhook] Error vinculando el teléfono compartido: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getUserByPhone($phoneNumber) {
|
||||
return $this->db->fetch(
|
||||
"SELECT * FROM users WHERE phone_number = :phone",
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
// esBsuid() vive en config.php; se garantiza aquí por si esta clase se incluye
|
||||
// directamente, sin pasar por el arranque del ERP.
|
||||
if (!function_exists('esBsuid')) {
|
||||
$configPaciente = __DIR__ . '/../../config/config.php';
|
||||
if (file_exists($configPaciente)) {
|
||||
require_once $configPaciente;
|
||||
}
|
||||
}
|
||||
|
||||
class Paciente {
|
||||
|
||||
private Database $db;
|
||||
@@ -173,8 +182,22 @@ class Paciente {
|
||||
}
|
||||
}
|
||||
|
||||
// Registrar antes y después de lo que realmente cambia. Guardar solo el
|
||||
// valor nuevo impide reconstruir el dato anterior si la corrección
|
||||
// resultó equivocada, que es justo cuando hace falta consultarlo.
|
||||
$antes = $this->db->fetch('SELECT * FROM lab_pacientes WHERE id = ?', [$id]) ?: [];
|
||||
$cambios = [];
|
||||
foreach ($campos as $campo => $nuevo) {
|
||||
$previo = $antes[$campo] ?? null;
|
||||
if ((string)$previo !== (string)$nuevo) {
|
||||
$cambios[$campo] = ['antes' => $previo, 'despues' => $nuevo];
|
||||
}
|
||||
}
|
||||
|
||||
$ok = $this->db->update('lab_pacientes', $campos, 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $campos);
|
||||
if ($cambios) {
|
||||
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $cambios);
|
||||
}
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
@@ -244,10 +267,19 @@ class Paciente {
|
||||
[$userId]
|
||||
);
|
||||
|
||||
// Quien oculta su teléfono en WhatsApp se identifica con un BSUID, que ocupa
|
||||
// el lugar del número en `users`. No es un teléfono: guardarlo aquí dejaría
|
||||
// en la historia clínica un dato falso con apariencia de número real, porque
|
||||
// normalizarTelefono() le quita el punto y las letras y lo deja en 16 dígitos.
|
||||
// Mejor la ficha sin teléfono, que es la verdad: no lo tenemos.
|
||||
$identificador = $user['phone_number'] ?? null;
|
||||
$esIdentificadorSinTelefono = esBsuid($identificador);
|
||||
|
||||
return $this->crear([
|
||||
'user_id' => $userId,
|
||||
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
|
||||
'telefono' => $user['phone_number'] ?? null,
|
||||
'nombre_completo'=> $user['name']
|
||||
?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
|
||||
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -339,6 +339,24 @@ function deleteConfigFromDB($key) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('esBsuid')) {
|
||||
/**
|
||||
* ¿Este identificador es un BSUID de Meta y no un número de teléfono?
|
||||
*
|
||||
* Desde que WhatsApp permite ocultar el número, quien lo oculta llega
|
||||
* identificado solo por su BSUID, con la forma "CO.1761088155094242".
|
||||
* Ese valor ocupa el lugar del teléfono dentro del bot, así que hay que
|
||||
* distinguirlo antes de tratarlo como si fuera un número real: guardarlo
|
||||
* en un campo de teléfono deja un dato falso con toda la pinta de verdadero.
|
||||
*
|
||||
* @param mixed $valor
|
||||
* @return bool
|
||||
*/
|
||||
function esBsuid($valor) {
|
||||
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $valor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el cache estático de configuraciones
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,14 @@ class Router
|
||||
private const PUBLIC_ROUTES = [
|
||||
'turnero/display',
|
||||
'turnero/kiosko',
|
||||
// Tablet de firma del paciente: la manipula el público y nadie va a
|
||||
// iniciar sesión en ella cada mañana. No queda abierta: se identifica
|
||||
// por la cookie del dispositivo y sin ella no muestra dato alguno.
|
||||
'turnero/firma',
|
||||
// Prueba de voces: hay que abrirla EN el televisor para saber qué voces
|
||||
// tiene ese equipo, y allí no hay sesión iniciada. No expone nada: solo
|
||||
// lista las voces del navegador y lee una frase de ejemplo inventada.
|
||||
'turnero/voces',
|
||||
];
|
||||
|
||||
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
|
||||
|
||||
@@ -70,6 +70,8 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
<option value="enfermeras">Enfermeras</option>
|
||||
<option value="formularios">Formularios</option>
|
||||
<option value="asignaciones">Asignaciones</option>
|
||||
<option value="turnero">Turnero</option>
|
||||
<option value="turnero_config">Turnero · configuración</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
@@ -85,6 +87,11 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
<option value="enviar">enviar</option>
|
||||
<option value="reprogramar">reprogramar</option>
|
||||
<option value="cambiar_estado">cambiar_estado</option>
|
||||
<option value="quitar_consentimiento">quitar_consentimiento</option>
|
||||
<option value="resetear_consentimiento">resetear_consentimiento</option>
|
||||
<option value="resetear_toma">resetear_toma</option>
|
||||
<option value="cancelar_toma_pendiente">cancelar_toma_pendiente</option>
|
||||
<option value="vincular_paciente">vincular_paciente</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
@@ -104,6 +111,18 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<!-- Salud de la voz del televisor: alimentada por turnero_tv_log.
|
||||
Responde de un vistazo «¿la pantalla está llamando bien?» sin
|
||||
tener que consultar la base a mano. -->
|
||||
<div class="card border-0 shadow-sm mb-3" id="card-voz-tv" style="display:none">
|
||||
<div class="card-body py-3">
|
||||
<h6 class="mb-2"><i class="fas fa-volume-up text-primary me-1"></i>
|
||||
Voz del televisor — últimos 7 días</h6>
|
||||
<div class="d-flex gap-4 flex-wrap mb-2" id="voz-tv-resumen"></div>
|
||||
<div id="voz-tv-incidentes" class="small"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div id="act-spinner" class="text-center py-5 text-muted">
|
||||
@@ -270,6 +289,53 @@ function verDetalle(i) {
|
||||
new bootstrap.Modal(document.getElementById('modalDetalle')).show();
|
||||
}
|
||||
|
||||
// ── Salud de la voz del televisor ────────────────────────────
|
||||
const VOZ_EVENTO_TXT = {
|
||||
mudo: ['🔇 No sonó', 'text-danger', 'El llamado salió sin voz: ni el reintento habló'],
|
||||
sin_start: ['⚠️ Arrancó con reintento','text-warning','La voz elegida no arrancó; habló la del navegador'],
|
||||
error: ['⚠️ Error del motor', 'text-warning', 'El motor de voz falló a mitad; se reintentó'],
|
||||
lag: ['⏸ Pantalla congelada', 'text-warning', 'La pestaña estuvo oculta o el equipo suspendido: en ese lapso no ve llamados. En el televisor debe estar siempre visible'],
|
||||
};
|
||||
|
||||
async function cargarVozTV() {
|
||||
try {
|
||||
const r = await fetch('api/lab/get_tv_log.php');
|
||||
const d = await r.json();
|
||||
if (!d.ok) return;
|
||||
// Sin datos todavía (recién desplegado): la tarjeta no aparece
|
||||
if (!d.anuncios_7d && !d.incidentes.length) return;
|
||||
|
||||
document.getElementById('card-voz-tv').style.display = '';
|
||||
const pct = d.anuncios_7d ? Math.round(d.completos_7d / d.anuncios_7d * 100) : 0;
|
||||
document.getElementById('voz-tv-resumen').innerHTML = `
|
||||
<div><span class="fw-bold fs-5">${d.anuncios_7d}</span>
|
||||
<span class="text-muted small">llamados con voz</span></div>
|
||||
<div><span class="fw-bold fs-5 ${pct >= 97 ? 'text-success' : 'text-warning'}">${pct}%</span>
|
||||
<span class="text-muted small">hablados completos</span></div>
|
||||
<div><span class="fw-bold fs-5 ${d.mudos_7d ? 'text-danger' : 'text-success'}">${d.mudos_7d}</span>
|
||||
<span class="text-muted small">salieron mudos</span></div>
|
||||
<div><span class="fw-bold fs-5 ${d.avisos_7d ? 'text-warning' : 'text-success'}">${d.avisos_7d}</span>
|
||||
<span class="text-muted small">se recuperaron con reintento</span></div>`;
|
||||
|
||||
const inc = document.getElementById('voz-tv-incidentes');
|
||||
if (!d.incidentes.length) {
|
||||
inc.innerHTML = '<span class="text-success"><i class="fas fa-check-circle me-1"></i>Sin fallos registrados</span>';
|
||||
return;
|
||||
}
|
||||
inc.innerHTML = '<div class="text-muted mb-1">Últimos fallos:</div>' +
|
||||
d.incidentes.slice(0, 10).map(i => {
|
||||
const [txt, cls, ayuda] = VOZ_EVENTO_TXT[i.evento] || [i.evento, '', ''];
|
||||
return `<div class="py-1 border-bottom" title="${ayuda}">
|
||||
<span class="text-muted">${(i.cuando || '').slice(0, 16)}</span>
|
||||
· turno <b>${i.turno || '—'}</b>
|
||||
· <span class="${cls}">${txt}</span>
|
||||
${i.voz ? `<span class="text-muted"> (voz: ${i.voz})</span>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (_) {}
|
||||
}
|
||||
cargarVozTV();
|
||||
|
||||
async function exportarCSV() {
|
||||
const params = new URLSearchParams({
|
||||
desde: document.getElementById('act-desde').value,
|
||||
|
||||
+35
-3
@@ -472,6 +472,28 @@ function fmtFecha(str) {
|
||||
return str.slice(0, 10).split('-').reverse().join('/');
|
||||
}
|
||||
|
||||
// Quien oculta su número en WhatsApp llega identificado con un BSUID
|
||||
// ("CO.1761088155094242"), que se guarda en phone_number pero no es un teléfono.
|
||||
const esBsuid = v => /^[A-Z]{2}\.\d+$/.test(String(v || ''));
|
||||
|
||||
/**
|
||||
* Celda de contacto. Si de esta persona no tenemos teléfono porque lo tiene
|
||||
* oculto, se dice así en vez de mostrar el identificador crudo: recepción
|
||||
* necesita entender por qué no puede llamarla, no ver un código.
|
||||
*/
|
||||
function celdaTelefono(p) {
|
||||
if (esBsuid(p.phone_number)) {
|
||||
return (p.telefono ? esc(p.telefono) : '<span class="text-muted">Sin teléfono</span>')
|
||||
+ '<br><i class="fab fa-whatsapp text-success"></i> '
|
||||
+ '<small class="text-muted" title="Tiene el número oculto en WhatsApp. '
|
||||
+ 'Se le puede escribir por el chat, pero no llamar.">Solo por WhatsApp</small>';
|
||||
}
|
||||
return esc(p.telefono || '—')
|
||||
+ (p.phone_number
|
||||
? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>`
|
||||
: '');
|
||||
}
|
||||
|
||||
async function cargarLista(pag = 1) {
|
||||
paginaActual = pag;
|
||||
const busq = document.getElementById('buscador').value.trim();
|
||||
@@ -495,7 +517,7 @@ async function cargarLista(pag = 1) {
|
||||
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
|
||||
</td>
|
||||
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
|
||||
<td class="small">${esc(p.telefono||'—')}${p.phone_number ? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>` : ''}</td>
|
||||
<td class="small">${celdaTelefono(p)}</td>
|
||||
<td class="small text-muted">${esc(p.email||'—')}</td>
|
||||
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
||||
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
||||
@@ -546,8 +568,18 @@ async function verDetalle(id) {
|
||||
document.getElementById('detail-body').innerHTML = `
|
||||
<dl class="row small mb-3">
|
||||
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${
|
||||
p.telefono ? esc(p.telefono)
|
||||
: (esBsuid(p.phone_number)
|
||||
? '<span class="text-muted">Sin teléfono — lo tiene oculto en WhatsApp</span>'
|
||||
: '—')
|
||||
}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${
|
||||
!p.phone_number ? '—'
|
||||
: (esBsuid(p.phone_number)
|
||||
? '<i class="fab fa-whatsapp text-success"></i> Se le puede escribir por el chat, pero no llamar'
|
||||
: `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}`)
|
||||
}</dd>
|
||||
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
|
||||
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
|
||||
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 20260811_bsuid_identidad_whatsapp.sql
|
||||
--
|
||||
-- Meta desplegó los nombres de usuario de WhatsApp: quien oculta su teléfono
|
||||
-- llega al webhook sin `from` ni `wa_id`, identificado solo por su BSUID
|
||||
-- (identificador de usuario por empresa), con la forma "CO.1761088155094242".
|
||||
--
|
||||
-- Ese identificador ocupa el lugar del teléfono en el flujo del bot, pero no
|
||||
-- cabe en varchar(20): los BSUID de Meta llegan a 23 caracteres, así que se
|
||||
-- truncarían en silencio y la persona quedaría imposible de responder.
|
||||
--
|
||||
-- Se amplían a varchar(32) las cuatro tablas por donde circula.
|
||||
-- Las vistas (scheduled_messages_view, v_paciente_resumen) heredan el tipo.
|
||||
-- lab_pacientes.telefono se deja igual: ahí va el teléfono real del paciente,
|
||||
-- y de estas personas justamente no lo tenemos.
|
||||
|
||||
ALTER TABLE users MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE user_states MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE terms_acceptance MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE file_requests MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 20260811_bsuid_mapa_identidad.sql
|
||||
--
|
||||
-- Segunda parte del cambio de identidad de WhatsApp (ver 20260811_bsuid_identidad_whatsapp.sql).
|
||||
--
|
||||
-- Meta manda el BSUID en TODOS los webhooks de mensaje, también en los que aún
|
||||
-- traen teléfono. Eso permite guardar la equivalencia BSUID↔teléfono mientras
|
||||
-- la persona todavía muestra su número, de modo que el día que lo oculte
|
||||
-- siga siendo reconocible: ya sabemos quién es.
|
||||
--
|
||||
-- El histórico de webhook_logs tiene 6.423 equivalencias que se cargan con
|
||||
-- scripts/backfill_bsuid.php.
|
||||
--
|
||||
-- Se guarda en una columna aparte y no en phone_number porque son dos cosas
|
||||
-- distintas: el BSUID identifica, el teléfono además sirve para cruzar con el
|
||||
-- paciente y el turnero. Mezclarlos rompería esos cruces.
|
||||
|
||||
ALTER TABLE users ADD COLUMN bsuid VARCHAR(32) NULL DEFAULT NULL COMMENT 'Identificador de usuario por empresa (Meta). Presente aunque la persona oculte su teléfono.' AFTER phone_number;
|
||||
|
||||
-- Único: un BSUID identifica a una sola persona dentro del portafolio.
|
||||
-- Admite varios NULL, que es el caso de todos los usuarios ya existentes.
|
||||
ALTER TABLE users ADD UNIQUE KEY uk_users_bsuid (bsuid);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 20260811_bsuid_pedir_contacto.sql
|
||||
--
|
||||
-- Tercera parte del cambio de identidad de WhatsApp.
|
||||
--
|
||||
-- A quien oculta su teléfono se le puede pedir con el botón request_contact_info.
|
||||
-- Se registra cuándo se le pidió para no volver a insistirle: pedirle los datos
|
||||
-- una vez es razonable, repetírselo en cada trámite es acoso.
|
||||
--
|
||||
-- Queda NULL para todo el mundo; solo se llena cuando efectivamente se pide.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN contacto_pedido_at DATETIME NULL DEFAULT NULL
|
||||
COMMENT 'Cuándo se le pidió el teléfono por el botón de WhatsApp. NULL = nunca.'
|
||||
AFTER bsuid;
|
||||
|
||||
-- Texto editable desde configuración, para que el laboratorio ajuste el mensaje
|
||||
-- sin tocar código. Si la fila ya existe, se respeta lo que haya.
|
||||
INSERT INTO system_config (config_key, config_value, description)
|
||||
VALUES (
|
||||
'whatsapp_texto_pedir_contacto',
|
||||
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?',
|
||||
'Mensaje del botón que pide el teléfono a quien lo tiene oculto en WhatsApp'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE config_key = config_key;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Estaciones accesibles desde equipos asignados a otra estación.
|
||||
-- Pediatría y Ginecología se atienden desde cualquier puesto, pero el bloqueo
|
||||
-- por IP o token devolvía al equipo a su estación sin avisar.
|
||||
-- Idempotente: puede correrse más de una vez.
|
||||
|
||||
ALTER TABLE turnero_lugares
|
||||
ADD COLUMN IF NOT EXISTS acceso_libre TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT 'Accesible desde equipos asignados a otra estación';
|
||||
|
||||
UPDATE turnero_lugares SET acceso_libre = 1
|
||||
WHERE nombre IN ('Pediatria', 'Ginecologia') AND acceso_libre = 0;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Rol de Calidad: consulta de turnos y tiempos de atención.
|
||||
-- Solo lectura, y dentro del turnero solo dashboard e historial.
|
||||
-- Incluye formularios: la coordinación del SIG administra los documentos
|
||||
-- además de revisar los tiempos del proceso.
|
||||
-- Idempotente: puede correrse más de una vez.
|
||||
|
||||
INSERT INTO roles (name, slug, description, color, home_page, is_system)
|
||||
SELECT 'Calidad', 'calidad',
|
||||
'Consulta de turnos y tiempos de atención. Solo lectura: dashboard e historial.',
|
||||
'#7c3aed', '/erp.php?m=turnero&v=dashboard', 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE slug = 'calidad');
|
||||
|
||||
INSERT INTO role_modules (role_id, module_slug, permission, can_view, can_create, can_edit, can_delete, can_export)
|
||||
SELECT r.id, m.slug, 'read', 1, 0, 0, 0, IF(m.slug = 'turnero', 1, 0)
|
||||
FROM roles r
|
||||
JOIN (SELECT 'turnero' AS slug UNION ALL SELECT 'soporte'
|
||||
UNION ALL SELECT 'lab_formularios') m
|
||||
WHERE r.slug = 'calidad'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_modules rm WHERE rm.role_id = r.id AND rm.module_slug = m.slug
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 20260823_tv_voz_log.sql
|
||||
--
|
||||
-- Bitácora de la voz del televisor. Los fallos son intermitentes ("a veces no
|
||||
-- suena, a veces entrecortado") y nadie puede depurarlos mirando la pantalla:
|
||||
-- hay que registrar cada intento de hablar CUANDO ocurre, con qué voz, si
|
||||
-- arrancó, cuánto tardó y cómo terminó. Con esto, la próxima vez que reporten
|
||||
-- "ayer a las 10 no sonó" se consulta esta tabla y se ve exactamente qué pasó.
|
||||
--
|
||||
-- La escribe modules/turnero/api/log_tv.php vía sendBeacon desde la pantalla.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS turnero_tv_log (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
evento VARCHAR(20) NOT NULL COMMENT 'start, end, error, sin_start, mudo',
|
||||
detalle VARCHAR(500) NULL COMMENT 'JSON: código del turno, voz, ms, duración',
|
||||
ip VARCHAR(45) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_evento_fecha (evento, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?id=N — Detalle de un examen del catálogo con sus ítems.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?exam_id=N — Tarifas de un examen por empresa o convenio.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?q=&categoria=&page=&limit= — Catálogo de exámenes, paginado y filtrable.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza un examen del catálogo. Requiere administrador.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza un ítem (analito) de un examen. Requiere administrador.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza la tarifa de un examen. Requiere administrador.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST { id } — Elimina un médico del catálogo.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET — Lista de médicos solicitantes.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza un médico del catálogo.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
* Convenciones:
|
||||
* docs/<seccion>/<NN>-<slug>.md → el prefijo NN solo ordena, no se muestra
|
||||
* El título sale del primer encabezado `# ` del archivo.
|
||||
*
|
||||
* Un documento puede restringir su visibilidad con una cabecera al inicio:
|
||||
*
|
||||
* ---
|
||||
* roles: enfermero, recepcionista
|
||||
* ---
|
||||
*
|
||||
* Sin cabecera, hereda el permiso de su sección. Los administradores ven todo.
|
||||
*/
|
||||
|
||||
final class DocIndex
|
||||
@@ -14,7 +22,7 @@ final class DocIndex
|
||||
/** Secciones, en orden de aparición. `admin` = solo administradores. */
|
||||
public const SECCIONES = [
|
||||
'manual' => ['titulo' => 'Manual de usuario', 'icono' => 'fas fa-book-reader', 'admin' => false,
|
||||
'resumen' => 'Cómo usar el sistema, paso a paso, según tu rol.'],
|
||||
'resumen' => 'Cómo usar el sistema, paso a paso, según su rol.'],
|
||||
'tecnica' => ['titulo' => 'Documentación técnica','icono' => 'fas fa-code', 'admin' => true,
|
||||
'resumen' => 'Cada módulo por dentro: tablas, endpoints y dependencias.'],
|
||||
'arquitectura'=> ['titulo' => 'Arquitectura', 'icono' => 'fas fa-sitemap', 'admin' => true,
|
||||
@@ -23,18 +31,63 @@ final class DocIndex
|
||||
'resumen' => 'Qué hacer cuando algo falla. Configuraciones críticas.'],
|
||||
];
|
||||
|
||||
/** Roles que ven todo, sin importar lo que declare cada documento. */
|
||||
public const ROLES_TOTALES = ['admin', 'superadmin'];
|
||||
|
||||
public static function dir(): string
|
||||
{
|
||||
return __DIR__ . '/docs';
|
||||
}
|
||||
|
||||
private static function rolActual(): string
|
||||
{
|
||||
return $_SESSION['admin_user']['role'] ?? '';
|
||||
}
|
||||
|
||||
private static function esAdmin(): bool
|
||||
{
|
||||
return in_array(self::rolActual(), self::ROLES_TOTALES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Separa la cabecera del cuerpo de un documento.
|
||||
* @return array{0: array<string,string>, 1: string} [metadatos, cuerpo]
|
||||
*/
|
||||
public static function leer(string $archivo): array
|
||||
{
|
||||
$texto = (string)@file_get_contents($archivo);
|
||||
if (!preg_match('/^---\R(.*?)\R---\R?(.*)$/s', $texto, $m)) {
|
||||
return [[], $texto];
|
||||
}
|
||||
$meta = [];
|
||||
foreach (preg_split('/\R/', $m[1]) as $linea) {
|
||||
if (preg_match('/^\s*([\w-]+)\s*:\s*(.*)$/', $linea, $kv)) {
|
||||
$meta[strtolower($kv[1])] = trim($kv[2]);
|
||||
}
|
||||
}
|
||||
return [$meta, ltrim($m[2])];
|
||||
}
|
||||
|
||||
/** ¿El usuario actual puede ver este documento concreto? */
|
||||
public static function puedeVerDoc(string $seccion, string $archivo): bool
|
||||
{
|
||||
if (self::esAdmin()) return true;
|
||||
if (!self::puedeVer($seccion)) return false;
|
||||
|
||||
[$meta] = self::leer($archivo);
|
||||
if (empty($meta['roles'])) return true; // sin cabecera: hereda la sección
|
||||
|
||||
$permitidos = array_filter(array_map('trim', explode(',', strtolower($meta['roles']))));
|
||||
return in_array(strtolower(self::rolActual()), $permitidos, true);
|
||||
}
|
||||
|
||||
/** ¿El usuario actual puede ver esta sección? */
|
||||
public static function puedeVer(string $seccion): bool
|
||||
{
|
||||
$cfg = self::SECCIONES[$seccion] ?? null;
|
||||
if (!$cfg) return false;
|
||||
if (!$cfg['admin']) return true;
|
||||
return in_array($_SESSION['admin_user']['role'] ?? '', ['admin', 'superadmin'], true);
|
||||
return self::esAdmin();
|
||||
}
|
||||
|
||||
/** Secciones visibles para el usuario actual. */
|
||||
@@ -60,6 +113,7 @@ final class DocIndex
|
||||
|
||||
$docs = [];
|
||||
foreach (glob($ruta . '/*.md') ?: [] as $archivo) {
|
||||
if (!self::puedeVerDoc($sec, $archivo)) continue;
|
||||
$base = basename($archivo, '.md');
|
||||
$slug = preg_replace('/^\d+-/', '', $base);
|
||||
$docs[] = [
|
||||
@@ -71,6 +125,9 @@ final class DocIndex
|
||||
}
|
||||
usort($docs, fn($a, $b) => strcmp($a['orden'], $b['orden']));
|
||||
|
||||
// Una sección sin documentos visibles no se muestra
|
||||
if (!$docs) continue;
|
||||
|
||||
$arbol[$sec] = $cfg + ['docs' => $docs];
|
||||
}
|
||||
return $arbol;
|
||||
@@ -84,7 +141,8 @@ final class DocIndex
|
||||
if (!preg_match('/^[a-z0-9-]+$/', $seccion) || !preg_match('/^[a-z0-9-]+$/', $slug)) return null;
|
||||
|
||||
foreach (glob(self::dir() . '/' . $seccion . '/*.md') ?: [] as $archivo) {
|
||||
if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) === $slug) return $archivo;
|
||||
if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) !== $slug) continue;
|
||||
return self::puedeVerDoc($seccion, $archivo) ? $archivo : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -103,6 +161,102 @@ final class DocIndex
|
||||
return ucfirst(str_replace('-', ' ', $slugFallback));
|
||||
}
|
||||
|
||||
/** Palabras que no aportan al puntaje de relevancia. */
|
||||
private const VACIAS = [
|
||||
'como','cual','cuales','donde','cuando','porque','para','pero','esta','este','esto',
|
||||
'con','sin','por','que','del','las','los','una','uno','del','sus','sobre','desde',
|
||||
'hacer','tengo','puedo','quiero','necesito','ayuda','favor','the','and','not','del',
|
||||
];
|
||||
|
||||
/** Secciones que puede consultar el asistente. Ver contextoIA(). */
|
||||
public const SECCIONES_IA = ['manual'];
|
||||
|
||||
/**
|
||||
* Selecciona los documentos más relevantes para una pregunta y devuelve su
|
||||
* texto, listo para dárselo a un modelo de lenguaje.
|
||||
*
|
||||
* Dos restricciones se aplican a la vez:
|
||||
*
|
||||
* 1. Por sección: el asistente solo consulta el manual de usuario. Está
|
||||
* para ayudar a usar el sistema, no a mantenerlo — la documentación
|
||||
* técnica, de arquitectura y de operación queda fuera incluso para
|
||||
* administradores, que pueden leerla directamente en el módulo.
|
||||
* 2. Por rol: dentro del manual solo entra lo que ese usuario podría leer
|
||||
* por su cuenta, así el asistente no puede revelar contenido ajeno.
|
||||
*
|
||||
* @return array{0: string, 1: array<int,string>} [contexto, títulos usados]
|
||||
*/
|
||||
public static function contextoIA(string $pregunta, int $maxDocs = 4, int $maxChars = 14000): array
|
||||
{
|
||||
$palabras = array_values(array_filter(
|
||||
preg_split('/[^a-záéíóúñü0-9]+/u', mb_strtolower($pregunta)),
|
||||
fn($p) => mb_strlen($p) > 2 && !in_array($p, self::VACIAS, true)
|
||||
));
|
||||
if (!$palabras) return ['', []];
|
||||
|
||||
// Textos de los documentos consultables
|
||||
$docs = [];
|
||||
foreach (self::arbol() as $sec => $cfg) {
|
||||
if (!in_array($sec, self::SECCIONES_IA, true)) continue;
|
||||
foreach ($cfg['docs'] as $doc) {
|
||||
[, $cuerpo] = self::leer($doc['archivo']);
|
||||
$docs[] = [
|
||||
'titulo' => $doc['titulo'],
|
||||
'seccion' => $cfg['titulo'],
|
||||
'cuerpo' => $cuerpo,
|
||||
'heno' => mb_strtolower($doc['titulo'] . ' ' . $cuerpo),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (!$docs) return ['', []];
|
||||
|
||||
// Peso de cada palabra según en cuántos documentos aparece: una que está
|
||||
// en todos ("paciente") no distingue nada; una que está en pocos sí.
|
||||
$total = count($docs);
|
||||
$peso = [];
|
||||
foreach ($palabras as $p) {
|
||||
$enCuantos = 0;
|
||||
foreach ($docs as $d) if (str_contains($d['heno'], $p)) $enCuantos++;
|
||||
$peso[$p] = $enCuantos ? log(1 + $total / $enCuantos) : 0;
|
||||
}
|
||||
|
||||
$candidatos = [];
|
||||
foreach ($docs as $d) {
|
||||
$puntaje = 0.0;
|
||||
foreach ($palabras as $p) {
|
||||
if (!$peso[$p]) continue;
|
||||
// El título pesa mucho más que una mención en el cuerpo
|
||||
$puntaje += substr_count(mb_strtolower($d['titulo']), $p) * 12 * $peso[$p];
|
||||
$puntaje += min(substr_count($d['heno'], $p), 6) * $peso[$p];
|
||||
}
|
||||
if ($puntaje > 0) $candidatos[] = ['puntaje' => $puntaje] + $d;
|
||||
}
|
||||
if (!$candidatos) return ['', []];
|
||||
|
||||
usort($candidatos, fn($a, $b) => $b['puntaje'] <=> $a['puntaje']);
|
||||
|
||||
// Umbral absoluto: por debajo son coincidencias sueltas de palabras
|
||||
// sin relación real con la pregunta, y darle eso al modelo lo lleva a
|
||||
// responder con lo que tenga a mano en vez de admitir que no sabe.
|
||||
if ($candidatos[0]['puntaje'] < 6) return ['', []];
|
||||
|
||||
// Y relativo: descartar lo que quede muy por debajo del mejor resultado
|
||||
$corte = $candidatos[0]['puntaje'] * 0.45;
|
||||
$candidatos = array_values(array_filter($candidatos, fn($c) => $c['puntaje'] >= $corte));
|
||||
$candidatos = array_slice($candidatos, 0, $maxDocs);
|
||||
|
||||
$porDoc = (int)floor($maxChars / count($candidatos));
|
||||
$ctx = '';
|
||||
$titulos = [];
|
||||
foreach ($candidatos as $c) {
|
||||
$texto = preg_replace('/\{\{\w+\}\}/', '', $c['cuerpo']); // los marcadores no aportan
|
||||
$texto = mb_substr(trim($texto), 0, $porDoc);
|
||||
$ctx .= "\n\n===== [{$c['seccion']}] {$c['titulo']} =====\n" . $texto;
|
||||
$titulos[] = $c['titulo'];
|
||||
}
|
||||
return [trim($ctx), $titulos];
|
||||
}
|
||||
|
||||
/**
|
||||
* Índice para el buscador: un registro por documento con su texto plano.
|
||||
* Solo incluye secciones visibles para el usuario actual.
|
||||
@@ -112,7 +266,7 @@ final class DocIndex
|
||||
$out = [];
|
||||
foreach (self::arbol() as $sec => $cfg) {
|
||||
foreach ($cfg['docs'] as $doc) {
|
||||
$texto = @file_get_contents($doc['archivo']) ?: '';
|
||||
[, $texto] = self::leer($doc['archivo']);
|
||||
// Aplanar: sin marcas, sin saltos, compacto
|
||||
$texto = preg_replace('/```.*?```/s', ' ', $texto);
|
||||
$texto = preg_replace('/[#>*_`|-]+/', ' ', $texto);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/api/ai_docs.php
|
||||
* POST { pregunta: string, historial?: [{rol,texto},...] }
|
||||
*
|
||||
* LIA respondiendo únicamente sobre la documentación del sistema. El contexto
|
||||
* lo arma DocIndex, que filtra por rol: solo entra lo que este usuario podría
|
||||
* leer por su cuenta en el módulo Soporte. No accede a datos de pacientes ni
|
||||
* de la operación del día.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once __DIR__ . '/../../../services/GeminiService.php';
|
||||
require_once __DIR__ . '/../DocIndex.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
function salir(array $datos, int $codigo = 200): void
|
||||
{
|
||||
http_response_code($codigo);
|
||||
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isUserLoggedIn()) salir(['ok' => false, 'error' => 'No autenticado'], 401);
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') salir(['ok' => false, 'error' => 'Método no permitido'], 405);
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$pregunta = trim($body['pregunta'] ?? '');
|
||||
|
||||
if ($pregunta === '') salir(['ok' => false, 'error' => 'Pregunta vacía'], 400);
|
||||
if (mb_strlen($pregunta) > 800) salir(['ok' => false, 'error' => 'Pregunta demasiado larga'], 400);
|
||||
|
||||
$gemini = new GeminiService(Database::getInstance()->getConnection());
|
||||
|
||||
if (!$gemini->hayClave()) {
|
||||
salir(['ok' => false, 'error' => 'El asistente no está configurado. Avisa a un administrador.'], 503);
|
||||
}
|
||||
if ($gemini->presupuestoAgotado()) {
|
||||
salir(['ok' => false, 'error' => '⚠️ Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.'], 402);
|
||||
}
|
||||
|
||||
// ── Documentación visible para este usuario, acotada a la pregunta ──
|
||||
[$contexto, $titulos] = DocIndex::contextoIA($pregunta);
|
||||
|
||||
if ($contexto === '') {
|
||||
salir([
|
||||
'ok' => true,
|
||||
'respuesta' => 'Eso no está en el manual de usuario. Pruebe con otras palabras, '
|
||||
. 'o revise el índice de la izquierda: puede estar en otra sección '
|
||||
. 'de la documentación.',
|
||||
'fuentes' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Historial de la conversación ───────────────────────────────
|
||||
$historial = [];
|
||||
foreach (array_slice(is_array($body['historial'] ?? null) ? $body['historial'] : [], -6) as $h) {
|
||||
$texto = trim((string)($h['texto'] ?? ''));
|
||||
if ($texto === '') continue;
|
||||
$historial[] = [
|
||||
'role' => (($h['rol'] ?? '') === 'model') ? 'model' : 'user',
|
||||
'parts' => [['text' => mb_substr($texto, 0, 1500)]],
|
||||
];
|
||||
}
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
Eres LIA, la asistente de documentación del ERP del Laboratorio Clínico Ximena Caicedo.
|
||||
|
||||
Responde preguntas sobre CÓMO USAR el sistema, apoyándose ÚNICAMENTE en el manual de usuario
|
||||
incluido abajo. Reglas:
|
||||
|
||||
- Si la respuesta no está en la documentación, dilo con claridad. No inventes ni supongas.
|
||||
- Solo conoce el manual de usuario, no la documentación técnica ni la de operación. Si le
|
||||
preguntan algo técnico (base de datos, código, configuración interna), diga que eso está en
|
||||
la documentación del módulo Soporte y que lo consulte allí.
|
||||
- El manual incluido es el que este usuario tiene permitido consultar. No mencione ni deduzca
|
||||
la existencia de contenido que no esté aquí.
|
||||
- Responde en español, tratando SIEMPRE de usted (nunca tú ni vos). Directo y práctico:
|
||||
si es un procedimiento, enumera los pasos.
|
||||
- Usa tablas o viñetas cuando ayuden a leer.
|
||||
- No inventes rutas, nombres de botones ni consultas SQL que no aparezcan en la documentación.
|
||||
|
||||
DOCUMENTACIÓN DISPONIBLE:
|
||||
{$contexto}
|
||||
PROMPT;
|
||||
|
||||
try {
|
||||
$r = $gemini->preguntar($prompt, $pregunta, $historial);
|
||||
salir([
|
||||
'ok' => true,
|
||||
'respuesta' => $r['respuesta'],
|
||||
'truncada' => $r['truncada'],
|
||||
'fuentes' => $titulos,
|
||||
'tokens_usados' => $r['tokens_usados'],
|
||||
'tokens_restantes' => $r['tokens_restantes'],
|
||||
'tokens_max' => $r['tokens_max'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
salir(['ok' => false, 'error' => $e->getMessage()], 502);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ En `admin_users` conviven dos campos que parecen redundantes y **no lo son**:
|
||||
|
||||
> **Hay que mantener las dos sincronizadas.** Cambiar solo `role` deja al usuario con los permisos viejos, porque el acceso real sale de `role_id`. Este error ya ocurrió: un usuario cambió de rol, la interfaz mostraba el rol nuevo y los módulos seguían siendo los anteriores.
|
||||
|
||||
Al cambiar el rol de alguien, actualizá las dos a la vez:
|
||||
Al cambiar el rol de alguien, actualice las dos a la vez:
|
||||
|
||||
```sql
|
||||
UPDATE admin_users
|
||||
|
||||
@@ -78,7 +78,7 @@ users / conversations el contacto y su hilo
|
||||
|
||||
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql`. La convención del repositorio es que sean **idempotentes** — `IF NOT EXISTS` y guardas en los `UPDATE`/`INSERT` — para poder correrlas más de una vez sin daño.
|
||||
|
||||
> Un cambio aplicado directo en producción sin dejar la migración correspondiente hace que un entorno nuevo no lo tenga. Si tocás el esquema, dejá el archivo.
|
||||
> Un cambio aplicado directo en producción sin dejar la migración correspondiente hace que un entorno nuevo no lo tenga. Si toca el esquema, deje el archivo.
|
||||
|
||||
## Inventario completo
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ No hay traza de auditoría que permita reconstruirlos.
|
||||
|
||||
## Al hacer cambios
|
||||
|
||||
- **Cambio de esquema** → dejá la migración en `migrations/`, idempotente.
|
||||
- **Endpoint nuevo** → verificá permisos ahí adentro, no confíes en la vista.
|
||||
- **Tocar el turnero** → es lo que más gente usa a diario; probá con un turno real.
|
||||
- **Cambio de esquema** → deje la migración en `migrations/`, idempotente.
|
||||
- **Endpoint nuevo** → verifique permisos ahí adentro, no confíes en la vista.
|
||||
- **Tocar el turnero** → es lo que más gente usa a diario; pruebe con un turno real.
|
||||
- **Tocar formularios firmados** → tienen valor legal. Un render roto es un documento inválido.
|
||||
|
||||
@@ -4,23 +4,23 @@ Lo mínimo para moverse por el sistema, sin importar el rol.
|
||||
|
||||
## Entrar
|
||||
|
||||
Se ingresa con usuario y contraseña. Si no reconocés tu usuario, buscá tu **número de cédula**: la mayoría de las cuentas del personal se crearon así.
|
||||
Se ingresa con usuario y contraseña. Si no reconoce su usuario, busque su **número de cédula**: la mayoría de las cuentas del personal se crearon así.
|
||||
|
||||
Al entrar vas directo a la pantalla principal de tu rol. No todos ven lo mismo: el menú de la izquierda muestra únicamente los módulos habilitados para vos.
|
||||
Al entrar va directo a la pantalla principal de su rol. No todos ven lo mismo: el menú de la izquierda muestra únicamente los módulos habilitados para usted.
|
||||
|
||||
## Si no ves algo que deberías ver
|
||||
## Si no ve algo que deberías ver
|
||||
|
||||
Casi siempre es una de estas dos:
|
||||
|
||||
1. **Te cambiaron los permisos hace poco.** Los permisos se cargan **al iniciar sesión**. Cerrá sesión y volvé a entrar.
|
||||
2. **Tu rol no lo incluye.** Pedile a un administrador que lo revise.
|
||||
1. **Le cambiaron los permisos hace poco.** Los permisos se cargan **al iniciar sesión**. Cierre sesión y vuelva a entrar.
|
||||
2. **Su rol no lo incluye.** Pídale a un administrador que lo revise.
|
||||
|
||||
## Cómo está organizado
|
||||
|
||||
| Zona | Qué contiene |
|
||||
|---|---|
|
||||
| Menú izquierdo | Los módulos a los que tenés acceso |
|
||||
| Barra superior | Tu usuario y el cierre de sesión |
|
||||
| Menú izquierdo | Los módulos a los que tiene acceso |
|
||||
| Barra superior | Su usuario y el cierre de sesión |
|
||||
| Centro | La pantalla activa |
|
||||
|
||||
## Los módulos principales
|
||||
@@ -34,19 +34,46 @@ Casi siempre es una de estas dos:
|
||||
| **Formularios** | Consentimientos y fichas; diseño y envíos |
|
||||
| **Soporte** | Esta documentación |
|
||||
|
||||
## El recorrido de un paciente
|
||||
|
||||
Desde que saca su turno hasta que se va:
|
||||
|
||||
```
|
||||
KIOSKO RECEPCIÓN TOMA DE MUESTRAS
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────┐ llama ┌──────┐ deriva ┌──────────────┐ ┌────────────┐
|
||||
│espera├──────────►│recep.├───────────►│ espera lugar ├──►│ en servicio│
|
||||
└──┬───┘ └──┬───┘ └──────────────┘ └──────┬─────┘
|
||||
│ │ │
|
||||
│ no responde │ ▼
|
||||
└──────────────────┴──────────► ausente ┌────────────────┐
|
||||
│ finalizado │
|
||||
cancelado └────────────────┘
|
||||
cuenta para facturar
|
||||
```
|
||||
|
||||
Un turno **nunca se borra**. Si el paciente no aparece se marca *ausente*; si no se hace, *cancelado*. Los dos quedan registrados, y ninguno cuenta como facturación.
|
||||
|
||||
## Cosas que conviene saber desde el principio
|
||||
|
||||
**Los turnos no se borran.** Se cancelan o se marcan como ausente, pero quedan registrados. Es a propósito: el historial tiene valor clínico y administrativo.
|
||||
|
||||
**Las firmas quedan con nombre y cédula.** Cuando firmás un formulario, el sistema registra quién sos. No es opcional ni configurable.
|
||||
**Las firmas quedan con nombre y cédula.** Cuando firma un formulario, el sistema registra quién es. No es opcional ni configurable.
|
||||
|
||||
**Una muestra pendiente no se pierde.** Si un paciente queda debiendo una muestra y vuelve otro día, aparece sola en la estación, marcada como *visita anterior*, con los exámenes de aquella orden.
|
||||
|
||||
**Nadie factura lo que no terminó.** En los reportes del día, lo cobrado en turnos finalizados y lo que sigue en curso se muestran por separado. Los ausentes y cancelados no se cuentan.
|
||||
|
||||
## Manual según tu rol
|
||||
## Manual según su rol
|
||||
|
||||
- [Kiosko y pantallas](?m=soporte&v=documentacion&s=manual&d=kiosko-y-pantallas)
|
||||
- [Recepción](?m=soporte&v=documentacion&s=manual&d=recepcion)
|
||||
- [Chat de WhatsApp](?m=soporte&v=documentacion&s=manual&d=chat-whatsapp)
|
||||
- [Órdenes médicas](?m=soporte&v=documentacion&s=manual&d=ordenes-medicas)
|
||||
- [Domicilios](?m=soporte&v=documentacion&s=manual&d=domicilios)
|
||||
- [Formularios](?m=soporte&v=documentacion&s=manual&d=formularios)
|
||||
- [Reportes y métricas](?m=soporte&v=documentacion&s=manual&d=reportes)
|
||||
- [Toma de muestras](?m=soporte&v=documentacion&s=manual&d=toma-de-muestras)
|
||||
- [Enfermeros — domicilios](?m=soporte&v=documentacion&s=manual&d=enfermeros)
|
||||
- [Administración](?m=soporte&v=documentacion&s=manual&d=administracion)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Kiosko y pantallas de TV
|
||||
|
||||
Las dos pantallas que funcionan solas, sin nadie operándolas. Conviene entenderlas porque cuando fallan, el que se entera primero es quien está en recepción.
|
||||
|
||||
## El kiosko
|
||||
|
||||
El tótem de la entrada, donde el paciente saca su turno sin ayuda.
|
||||
|
||||
```
|
||||
PACIENTE LLEGA
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Elige tipo │ general, preferencial, embarazada,
|
||||
│ de turno │ solo entrega de muestras…
|
||||
└──────┬───────┘
|
||||
▼
|
||||
┌──────────────┐
|
||||
│Datos básicos │ nombre y celular (opcional)
|
||||
└──────┬───────┘
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Imprime │──► el turno aparece en la pantalla de TV
|
||||
│ su turno │ y en la cola de recepción
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
Es una de las **dos únicas pantallas públicas** del sistema: no pide usuario ni contraseña, porque nadie va a iniciar sesión en el tótem de la entrada.
|
||||
|
||||
### Prioridades
|
||||
|
||||
El tipo de turno que elige el paciente define su lugar en la cola. Las prioridades las configura un administrador; quien atiende no necesita hacer nada: **Llamar siguiente** ya respeta el orden.
|
||||
|
||||
### Si el paciente deja su celular
|
||||
|
||||
Puede recibir por WhatsApp el aviso de su turno y los consentimientos para firmar desde el teléfono mientras espera. Vale la pena insistirle en que lo deje.
|
||||
|
||||
### Cuando el kiosko falla
|
||||
|
||||
Un turno siempre se puede crear a mano desde recepción. El kiosko es una comodidad, no un requisito — si está caído, la atención sigue.
|
||||
|
||||
## Pantalla de TV
|
||||
|
||||
La de la sala de espera. Muestra el turno que se está llamando y la cola, y reproduce contenido del laboratorio de fondo.
|
||||
|
||||
También es pública: se abre en el navegador del televisor y se deja andando.
|
||||
|
||||
### El contenido de fondo
|
||||
|
||||
Es una lista de videos e imágenes que se reproducen en bucle, uno detrás de otro:
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ video 1 │──►│ imagen │──►│ video 2 │──►│ imagen │──┐
|
||||
│ hasta │ │ 8 seg │ │ hasta │ │ 10 seg │ │
|
||||
│ el final│ │ │ │ el final│ │ │ │
|
||||
└─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
▲ │
|
||||
└──────────────────── vuelve a empezar ────────────┘
|
||||
```
|
||||
|
||||
Los videos van completos; a las imágenes se les fija cuántos segundos duran. Se administra desde **Configuración del turnero → Pantalla TV**: se suben, se reordenan arrastrando y se eliminan de a uno.
|
||||
|
||||
### Si la pantalla se queda pegada
|
||||
|
||||
Recargue la página en el televisor. Si el contenido no cambió, verifique que la lista tenga elementos activos en la configuración.
|
||||
|
||||
## Verificar paciente
|
||||
|
||||
Una consulta rápida por cédula para ver la ficha y el historial de alguien, sin abrir su turno. Útil cuando el paciente pregunta algo en el mostrador y no quiere perder lo que está haciendo.
|
||||
@@ -1,18 +1,22 @@
|
||||
---
|
||||
roles: recepcionista, lab_recepcion, supervisor
|
||||
---
|
||||
|
||||
# Recepción
|
||||
|
||||
Guía de la pantalla de recepción del turnero: desde que llamás al paciente hasta que pasa a toma de muestras.
|
||||
Guía de la pantalla de recepción del turnero: desde que llama al paciente hasta que pasa a toma de muestras.
|
||||
|
||||
## Tu escritorio
|
||||
## Su escritorio
|
||||
|
||||
Si el equipo está registrado por IP o token, el menú te muestra **solo tu escritorio**. Si no lo está, ves todos y elegís.
|
||||
Si el equipo está registrado por IP o token, el menú le muestra **solo su escritorio**. Si no lo está, ve todos y elige.
|
||||
|
||||
Esto lo configura un administrador en `Configuración del turnero`. Si estás viendo escritorios que no son el tuyo, avisá: significa que ese equipo no quedó registrado.
|
||||
Esto lo configura un administrador en `Configuración del turnero`. Si está viendo escritorios que no son el tuyo, avise: significa que ese equipo no quedó registrado.
|
||||
|
||||
## El flujo completo
|
||||
|
||||
### 1. Llamar al paciente
|
||||
|
||||
**Llamar siguiente** toma el turno con mayor prioridad de la cola. También podés llamar a uno específico si hace falta saltarse el orden.
|
||||
**Llamar siguiente** toma el turno con mayor prioridad de la cola. También puede llamar a uno específico si hace falta saltarse el orden.
|
||||
|
||||
El turno aparece en la pantalla de TV de la sala de espera.
|
||||
|
||||
@@ -20,54 +24,70 @@ Si el paciente no responde, **Marcar ausente**. Queda registrado como ausente y
|
||||
|
||||
### 2. Verificar o crear el paciente
|
||||
|
||||
Buscá por cédula o nombre.
|
||||
Busque por cédula o nombre.
|
||||
|
||||
- **Existe** → se cargan sus datos y su historial.
|
||||
- **No existe** → creá la ficha. Cédula, nombre completo, fecha de nacimiento, teléfono y EPS.
|
||||
- **No existe** → cree la ficha. Cédula, nombre completo, fecha de nacimiento, teléfono y EPS.
|
||||
|
||||
> El teléfono importa más de lo que parece: es a donde se envían los consentimientos y las encuestas. Un número mal escrito significa un consentimiento que nunca llega.
|
||||
|
||||
### 3. Seleccionar exámenes
|
||||
|
||||
Cargá los exámenes solicitados. Si viene con una orden en RIPS, se pueden importar directamente en vez de cargarlos a mano.
|
||||
Cargue los exámenes solicitados. Si viene con una orden en RIPS, se pueden importar directamente en vez de cargarlos a mano.
|
||||
|
||||
Al elegir los exámenes, el sistema decide solo qué consentimientos hacen falta: algunos van atados a un examen concreto (VIH, por ejemplo) y otros a la estación de destino.
|
||||
|
||||
### 4. Datos de facturación
|
||||
|
||||
Valor cobrado, método de pago, número de recibo. Si es por empresa o EPS, cargá el NIT y la autorización.
|
||||
Valor cobrado, método de pago, número de recibo. Si es por empresa o EPS, cargue el NIT y la autorización.
|
||||
|
||||
Se admite pago combinado — efectivo más tarjeta, por ejemplo.
|
||||
|
||||
### 5. Consentimientos
|
||||
|
||||
Quién firma qué, y dónde:
|
||||
|
||||
```
|
||||
RECEPCIÓN TOMA DE MUESTRAS
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Consentimiento │ │ Datos Toma de │
|
||||
│ pruebas de lab │ │ Muestras (F-LAB-08) │
|
||||
│ │ │ │
|
||||
│ firma: EL PACIENTE │ │ firma: EL PERSONAL │
|
||||
│ vía WhatsApp │ │ en la estación │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
obligatorio obligatorio SIEMPRE
|
||||
salvo "solo entrega" (incluso solo entrega)
|
||||
```
|
||||
|
||||
|
||||
Los que hagan falta aparecen listados con su estado. Se envían al WhatsApp del paciente, que los firma desde el celular.
|
||||
|
||||
**No podés guardar la solicitud si quedan consentimientos sin firmar**, salvo que sea una visita de *solo entrega de muestras*.
|
||||
**No puede guardar la solicitud si quedan consentimientos sin firmar**, salvo que sea una visita de *solo entrega de muestras*.
|
||||
|
||||
Si el envío por WhatsApp falla, el sistema manda un enlace en texto plano como respaldo.
|
||||
|
||||
### 6. Elegir destino y guardar
|
||||
|
||||
Seleccioná la estación de toma de muestras y guardá. El turno pasa a esa estación y el paciente sale de tu escritorio.
|
||||
Seleccione la estación de toma de muestras y guarde. El turno pasa a esa estación y el paciente sale de su escritorio.
|
||||
|
||||
## Situaciones frecuentes
|
||||
|
||||
### El paciente solo viene a entregar una muestra
|
||||
|
||||
Marcá **Solo entrega de muestras**. Se saltan los consentimientos por examen y no hace falta seleccionar exámenes.
|
||||
Marque **Solo entrega de muestras**. Se saltan los consentimientos por examen y no hace falta seleccionar exámenes.
|
||||
|
||||
> Ojo: el formulario **Datos Toma de Muestras (F-LAB-08)** se sigue exigiendo en la estación. Ese no se omite nunca, porque recoge la historia clínica del momento de la toma.
|
||||
|
||||
### El paciente ya vino antes y quedó debiendo una muestra
|
||||
|
||||
No tenés que hacer nada especial. En la estación de toma de muestras le va a aparecer sola, marcada como *visita anterior*, junto con los exámenes de aquella orden.
|
||||
No tiene que hacer nada especial. En la estación de toma de muestras le va a aparecer sola, marcada como *visita anterior*, junto con los exámenes de aquella orden.
|
||||
|
||||
### El paciente no recibió el consentimiento
|
||||
|
||||
1. Verificá el número de teléfono en su ficha.
|
||||
1. Verifique el número de teléfono en su ficha.
|
||||
2. Reenvialo desde la lista de consentimientos.
|
||||
3. Si sigue sin llegar, avisá a un administrador: puede ser un problema de la plantilla en Meta, que no se arregla desde acá.
|
||||
3. Si sigue sin llegar, avise a un administrador: puede ser un problema de la plantilla en Meta, que no se arregla desde acá.
|
||||
|
||||
### Hay que corregir algo después de guardar
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
roles: recepcionista, lab_recepcion, operador_bot, supervisor
|
||||
---
|
||||
|
||||
# Chat de WhatsApp
|
||||
|
||||
La línea de WhatsApp del laboratorio la atiende un bot, pero cuando hace falta una persona, la conversación pasa a un operador. Esta es esa pantalla.
|
||||
|
||||
## Cómo se reparte el trabajo
|
||||
|
||||
```
|
||||
MENSAJE DEL PACIENTE
|
||||
│
|
||||
▼
|
||||
┌─────────────┐ no ┌──────────────────────┐
|
||||
│ ¿Aceptó los ├────────►│ Le pide aceptar y │
|
||||
│ términos? │ │ no avanza hasta que │
|
||||
└──────┬──────┘ │ responda ACEPTO │
|
||||
│ sí └──────────────────────┘
|
||||
▼
|
||||
┌─────────────┐ sí ┌──────────────────────┐
|
||||
│ ¿La tomó un ├────────►│ El bot NO interviene │
|
||||
│ operador? │ │ Responde usted │
|
||||
└──────┬──────┘ └──────────────────────┘
|
||||
│ no
|
||||
▼
|
||||
┌─────────────┐ fuera ┌──────────────────────┐
|
||||
│ ¿Está en ├────────►│ Responde con el │
|
||||
│ horario? │ │ mensaje de fuera de │
|
||||
└──────┬──────┘ │ horario │
|
||||
│ dentro └──────────────────────┘
|
||||
▼
|
||||
El bot responde
|
||||
```
|
||||
|
||||
Lo importante: **cuando toma una conversación, el bot deja de responder ahí**. No hay riesgo de que le conteste encima al paciente mientras usted está escribiendo.
|
||||
|
||||
## Lo que el bot resuelve solo
|
||||
|
||||
Antes de llegar a una persona, el bot responde por su cuenta las preguntas más
|
||||
frecuentes —precios, horarios, ubicación, indicaciones previas— y ofrece un menú
|
||||
numerado para agendar, consultar resultados o ver el portafolio.
|
||||
|
||||
Ese contenido es configurable: si una respuesta quedó desactualizada o hace falta
|
||||
una nueva, un administrador la cambia sin necesidad de programar nada.
|
||||
|
||||
## Atender una conversación
|
||||
|
||||
La lista muestra los hilos con mensajes recientes. Al abrir uno ve el historial completo y puede responder.
|
||||
|
||||
Si el paciente ya está registrado, se ve su ficha; si no, se puede crear desde ahí mismo.
|
||||
|
||||
## La ventana de 24 horas
|
||||
|
||||
Es una regla de WhatsApp, no del sistema:
|
||||
|
||||
> Puede escribir libremente durante **24 horas** desde el último mensaje del paciente. Pasado ese plazo, solo se le puede escribir con una **plantilla aprobada**.
|
||||
|
||||
Por eso a veces el sistema no le deja mandar un texto libre y ofrece plantillas. No es una falla: es la restricción de WhatsApp.
|
||||
|
||||
Las plantillas las crea y aprueba Meta. Si necesita una nueva para un caso que se repite, pídala a un administrador — el trámite lleva días.
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
Todo contacto nuevo debe aceptar los términos antes de que el bot converse. Responde **ACEPTO** o **NO ACEPTO**.
|
||||
|
||||
Se le vuelven a pedir cuando pasan 6 meses o cuando se publica una versión nueva. Si un paciente dice que no puede abrir el documento de términos, avise: puede ser que el enlace esté caído, y eso lo resuelve un administrador.
|
||||
|
||||
## Problemas frecuentes
|
||||
|
||||
**El paciente dice que escribió y nadie le respondió.**
|
||||
Revise si la conversación quedó tomada por un operador que no siguió. En ese estado el bot no responde y queda esperando a una persona.
|
||||
|
||||
**No me deja enviar un mensaje.**
|
||||
Pasaron más de 24 horas desde el último mensaje del paciente. Use una plantilla.
|
||||
|
||||
**El paciente no recibe los consentimientos.**
|
||||
Verifique el número en su ficha. Si está bien y aun así no llegan, es problema de plantilla — avise a un administrador.
|
||||
@@ -1,14 +1,18 @@
|
||||
---
|
||||
roles: bacteriologo, supervisor
|
||||
---
|
||||
|
||||
# Toma de muestras
|
||||
|
||||
Guía de la estación de toma de muestras: atender al paciente, recibir sus muestras y firmar los formularios.
|
||||
|
||||
## Tu estación
|
||||
## Su estación
|
||||
|
||||
Igual que en recepción, si el equipo está registrado por IP o token, ves **solo tu estación**. Si no, las ves todas.
|
||||
Igual que en recepción, si el equipo está registrado por IP o token, ve **solo su estación**. Si no, las ve todas.
|
||||
|
||||
## Atender un turno
|
||||
|
||||
Los pacientes derivados desde recepción aparecen en tu bandeja. Al abrir uno ves su ficha completa: datos, exámenes solicitados, muestras a recibir y formularios pendientes.
|
||||
Los pacientes derivados desde recepción aparecen en su bandeja. Al abrir uno ve su ficha completa: datos, exámenes solicitados, muestras a recibir y formularios pendientes.
|
||||
|
||||
## Recibir muestras
|
||||
|
||||
@@ -24,9 +28,22 @@ Al rechazar hay que indicar el motivo. Ese motivo queda registrado y se ve despu
|
||||
|
||||
### Muestras de visitas anteriores
|
||||
|
||||
Si el paciente quedó debiendo una muestra otro día, te aparece con una etiqueta ámbar **visita anterior**, e incluye los exámenes de aquella orden para que sepas de qué se trataba.
|
||||
```
|
||||
VISITA 1 · lunes VISITA 2 · jueves
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ Turno A-042 │ │ Turno B-017 │
|
||||
│ │ │ │
|
||||
│ Sangre recibida │ │ Orina recibida │
|
||||
│ Orina PENDIENTE ├───────────────►│ └ visita anterior│
|
||||
└────────────────────┘ reaparece └────────────────────┘
|
||||
finalizado sola │
|
||||
NO se modifica ◄─────────────────────────────
|
||||
quedan enlazados
|
||||
```
|
||||
|
||||
Se reciben con un clic, igual que cualquier otra. Al hacerlo, los dos turnos quedan enlazados: desde el historial podés saltar de uno al otro.
|
||||
Si el paciente quedó debiendo una muestra otro día, le aparece con una etiqueta ámbar **visita anterior**, e incluye los exámenes de aquella orden para que sepa de qué se trataba.
|
||||
|
||||
Se reciben con un clic, igual que cualquier otra. Al hacerlo, los dos turnos quedan enlazados: desde el historial puede saltar de uno al otro.
|
||||
|
||||
> **El turno original no se modifica.** Sigue finalizado como estaba. Solo se registra en qué visita se completó la muestra.
|
||||
|
||||
@@ -36,20 +53,36 @@ Se reciben con un clic, igual que cualquier otra. Al hacerlo, los dos turnos que
|
||||
|
||||
Obligatorio en todas las estaciones. Recoge la historia clínica del momento: síntomas, antecedentes familiares y personales, medicación, datos obstétricos si corresponde.
|
||||
|
||||
Lo firmás vos, no el paciente.
|
||||
Lo firma usted, no el paciente.
|
||||
|
||||
**Si el paciente ya lo llenó en una visita anterior**, aparece el botón *Cargar datos de la visita anterior*. Trae las respuestas de la última vez para que solo revises y ajustes lo que cambió. **No trae la firma**: esa la ponés vos, con la fecha de hoy.
|
||||
**Si el paciente ya lo llenó en una visita anterior**, aparece el botón *Cargar datos de la visita anterior*. Trae las respuestas de la última vez para que solo revises y ajustes lo que cambió. **No trae la firma**: esa la pone usted, con la fecha de hoy.
|
||||
|
||||
### Control de Tomas de Muestras Prolongadas (F-LAB-28)
|
||||
|
||||
Para exámenes que requieren varias tomas en el tiempo: curvas de glicemia, prolactina, cortisol, test de Sullivan.
|
||||
|
||||
```
|
||||
Glicemia pre y post carga
|
||||
|
||||
min 0 min 30 min 60 min 120
|
||||
│ │ │ │
|
||||
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
|
||||
│ ✔ │ ────► │ ✔ │ ────► │ │ ... │ │
|
||||
└─────┘ 30min └─────┘ 30min └─────┘ └─────┘
|
||||
07:28 08:06 pendiente bloqueada
|
||||
M. Monterrosa Y. Parada ▲
|
||||
└ cuenta regresiva
|
||||
|
||||
Cada toma guarda SU hora y QUIÉN la firmó. Si cambia
|
||||
el turno del personal, cada firma conserva su nombre.
|
||||
```
|
||||
|
||||
Cómo funciona:
|
||||
|
||||
1. **Marcá el examen.** El formulario muestra solo las tomas de ese examen; si el paciente tiene dos exámenes seriados, muestra las de ambos.
|
||||
2. **Configurá los tiempos** si te lo pide (minuto 0, 30, 60…).
|
||||
3. **Firmá cada toma** a medida que la hacés. El sistema registra la hora y **quién firmó**.
|
||||
4. Cuando firmás una, el sistema calcula cuándo toca la siguiente y muestra una cuenta regresiva.
|
||||
1. **Marque el examen.** El formulario muestra solo las tomas de ese examen; si el paciente tiene dos exámenes seriados, muestra las de ambos.
|
||||
2. **Configure los tiempos** si se lo pide (minuto 0, 30, 60…).
|
||||
3. **Firme cada toma** a medida que la hace. El sistema registra la hora y **quién firmó**.
|
||||
4. Cuando firma una, el sistema calcula cuándo toca la siguiente y muestra una cuenta regresiva.
|
||||
|
||||
> Cada toma se firma por separado y queda con el nombre de quien la hizo. Si cambia el turno del personal a mitad del protocolo, cada toma conserva el nombre correcto.
|
||||
|
||||
@@ -57,11 +90,11 @@ Si hay que cerrar el protocolo antes de terminar todas las tomas, se puede hacer
|
||||
|
||||
## Antes de finalizar
|
||||
|
||||
El sistema no te deja finalizar si quedan muestras sin decidir. Cada una tiene que estar recibida, pendiente o rechazada.
|
||||
El sistema no le deja finalizar si quedan muestras sin decidir. Cada una tiene que estar recibida, pendiente o rechazada.
|
||||
|
||||
## Comentarios
|
||||
|
||||
Podés dejar notas en el turno. Quedan visibles para el resto del personal y en el historial.
|
||||
Puede dejar notas en el turno. Quedan visibles para el resto del personal y en el historial.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
@@ -69,7 +102,7 @@ Podés dejar notas en el turno. Quedan visibles para el resto del personal y en
|
||||
Sí. Con el botón de deshacer vuelve a pendiente.
|
||||
|
||||
**El formulario me muestra secciones de exámenes que el paciente no tiene.**
|
||||
Avisá a soporte. Debería mostrar únicamente las del examen marcado.
|
||||
Avise a soporte. Debería mostrar únicamente las del examen marcado.
|
||||
|
||||
**¿Qué pasa si el paciente se va sin dar una muestra?**
|
||||
Dejala en **pendiente**. Cuando vuelva —el día que sea— le va a aparecer sola a quien lo atienda.
|
||||
Déjela en **pendiente**. Cuando vuelva —el día que sea— le va a aparecer sola a quien lo atienda.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
roles: lab_readonly, recepcionista, lab_recepcion, supervisor
|
||||
---
|
||||
|
||||
# Órdenes médicas
|
||||
|
||||
Las órdenes que el paciente envía por WhatsApp, casi siempre como foto de la orden en papel. Acá se revisan y se autorizan.
|
||||
|
||||
## El recorrido
|
||||
|
||||
```
|
||||
PACIENTE USTED RESULTADO
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌───────────────┐ ┌────────────────┐
|
||||
│ envía │───────────►│ PENDIENTE │───────────►│ AUTORIZADA │
|
||||
│ la foto │ │ la revisa │ │ puede atenderse│
|
||||
└─────────┘ └───────┬───────┘ └────────────────┘
|
||||
│
|
||||
└──────────────────► ┌────────────────┐
|
||||
│ RECHAZADA │
|
||||
│ con comentario│
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
Toda orden llega como **pendiente**. Queda registrado quién la revisó, quién la autorizó y cuándo.
|
||||
|
||||
## Revisar una orden
|
||||
|
||||
Al abrirla ve la imagen que envió el paciente y los campos para transcribir lo que dice:
|
||||
|
||||
| Dato | Por qué importa |
|
||||
|---|---|
|
||||
| Médico y registro | Identifica quién la solicitó |
|
||||
| Fecha de la orden | Una orden vieja puede estar vencida |
|
||||
| Diagnóstico | Va en los reportes y en la facturación |
|
||||
| Exámenes solicitados | Define qué se le va a hacer |
|
||||
| **Requiere ayuno** y horas | Determina qué se le indica al paciente antes de venir |
|
||||
| Indicaciones | Cualquier preparación adicional |
|
||||
|
||||
> El ayuno es el dato que más consecuencias tiene si se transcribe mal: un paciente que viene sin ayunar cuando debía obliga a repetir la visita.
|
||||
|
||||
## Autorizar o rechazar
|
||||
|
||||
- **Autorizar** — la orden queda lista y el paciente puede ser atendido.
|
||||
- **Rechazar** — hay que dejar un comentario explicando por qué. Ese comentario es lo que permite entender después qué pasó.
|
||||
|
||||
Motivos frecuentes de rechazo: foto ilegible, orden vencida, faltan datos del médico, o los exámenes no corresponden a los que presta el laboratorio.
|
||||
|
||||
## Si solo tiene permiso de lectura
|
||||
|
||||
Algunos roles ven las órdenes sin poder modificarlas. En ese caso no aparecen los botones de autorizar ni rechazar: puede consultarlas e informarse, pero la decisión la toma otra persona.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**La foto llegó ilegible.**
|
||||
Rechace la orden indicándolo en el comentario, para que se le pueda pedir al paciente que la envíe de nuevo.
|
||||
|
||||
**El paciente dice que envió la orden y no aparece.**
|
||||
Verifique en el chat de WhatsApp que el mensaje haya llegado. Si llegó pero no se registró como orden, avise a un administrador.
|
||||
|
||||
**Me equivoqué al transcribir un dato.**
|
||||
Mientras la orden no esté autorizada se puede corregir. Si ya lo está, pídale a un administrador que la reabra.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
roles: recepcionista, lab_recepcion, lab_readonly, supervisor
|
||||
---
|
||||
|
||||
# Domicilios
|
||||
|
||||
La pantalla administrativa de las visitas domiciliarias: agendarlas, asignarles enfermero y seguirlas.
|
||||
|
||||
> Es distinta del **portal del enfermero**. Aquí se ve y administra todo; el portal muestra a cada enfermero solo lo suyo y está pensado para el celular.
|
||||
|
||||
## Agendar una visita
|
||||
|
||||
1. **Paciente** — búsquelo por cédula. Si no existe, se crea en el momento.
|
||||
2. **Dirección** — la de su ficha con un botón, o escriba otra. Agregue indicaciones si el lugar es difícil de ubicar.
|
||||
3. **Fecha y hora**.
|
||||
4. **Servicio** — qué se va a hacer.
|
||||
5. **Seguro y autorización**, si aplica.
|
||||
6. **Valores** — domicilio y copago.
|
||||
|
||||
## Asignar un enfermero
|
||||
|
||||
Una visita agendada queda **sin asignar** hasta que se le pone un enfermero. Las sin asignar del día son las que hay que resolver primero: nadie las va a atender solo porque estén agendadas.
|
||||
|
||||
Al asignarla, la visita aparece en el portal de ese enfermero, que puede editarla.
|
||||
|
||||
## Estados
|
||||
|
||||
```
|
||||
┌────────────┐ ┌───────────┐ ┌──────────────┐
|
||||
│ programado ├──►│ en curso ├──►│ completado │
|
||||
└─────┬──────┘ └───────────┘ └──────────────┘
|
||||
│
|
||||
└────────────────────────► ┌──────────────┐
|
||||
│ cancelado │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## Notas y archivos
|
||||
|
||||
Cada visita admite notas de seguimiento, con fotos y archivos adjuntos. Sirve para dejar la orden médica en papel, un resultado o una observación de lo ocurrido.
|
||||
|
||||
Las notas quedan visibles para quien atienda después. Es el lugar correcto para dejar constancia de algo que el próximo necesita saber.
|
||||
|
||||
## Formularios
|
||||
|
||||
Se le puede enviar un formulario al paciente para que lo firme desde su celular, o copiar el enlace para hacérselo llegar por otro medio. Los ya firmados se consultan desde la misma visita.
|
||||
|
||||
## Si solo tiene permiso de lectura
|
||||
|
||||
Algunos roles ven los domicilios sin poder modificarlos: no aparecen los botones de crear ni editar. Puede consultar la agenda, el estado de cada visita y sus notas.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**Agendé una visita y el enfermero dice que no la ve.**
|
||||
Verifique que le haya asignado el enfermero. Sin asignación, la visita no aparece en ningún portal.
|
||||
|
||||
**Hay que reprogramar.**
|
||||
Edite la fecha y la hora, y avísele al paciente. Desde el portal del enfermero hay un enlace que abre WhatsApp con el mensaje ya redactado.
|
||||
|
||||
**El paciente cambió de dirección.**
|
||||
Edite la visita. Si el cambio es permanente, actualice también la ficha del paciente, o la próxima vez volverá a aparecer la anterior.
|
||||
|
||||
**¿Puedo ver los domicilios de todos los enfermeros?**
|
||||
Desde esta pantalla sí. El portal, en cambio, muestra a cada enfermero solo los suyos.
|
||||
@@ -1,10 +1,14 @@
|
||||
---
|
||||
roles: enfermero, supervisor
|
||||
---
|
||||
|
||||
# Enfermeros — domicilios
|
||||
|
||||
Guía del portal del enfermero: tus visitas domiciliarias, cómo agendarlas y qué hacer en cada una.
|
||||
Guía del portal del enfermero: sus visitas domiciliarias, cómo agendarlas y qué hacer en cada una.
|
||||
|
||||
## Tu portal
|
||||
## Su portal
|
||||
|
||||
Al entrar vas directo al portal. Ves **tus** domicilios: los que te asignaron y los que agendaste vos.
|
||||
Al entrar va directo al portal. Ve **sus** domicilios: los que le asignaron y los que agendó usted.
|
||||
|
||||
Está pensado para usarse desde el celular en la calle.
|
||||
|
||||
@@ -12,8 +16,8 @@ Está pensado para usarse desde el celular en la calle.
|
||||
|
||||
**Nuevo domicilio** abre el formulario:
|
||||
|
||||
1. **Paciente** — buscalo por cédula. Si no existe, se crea ahí mismo.
|
||||
2. **Dirección** — la del paciente con un botón, o escribí otra. Agregá indicaciones si el lugar es difícil de encontrar («apto 302, tocar campanilla»).
|
||||
1. **Paciente** — búsquelo por cédula. Si no existe, se crea ahí mismo.
|
||||
2. **Dirección** — la del paciente con un botón, o escriba otra. Agregue indicaciones si el lugar es difícil de encontrar («apto 302, tocar campanilla»).
|
||||
3. **Fecha y hora**.
|
||||
4. **Servicio** — qué se va a hacer.
|
||||
5. **Seguro y autorización** si aplica.
|
||||
@@ -21,15 +25,15 @@ Está pensado para usarse desde el celular en la calle.
|
||||
|
||||
## Editar
|
||||
|
||||
Podés editar los domicilios **que agendaste vos** y también **los que te asignaron**. El botón *Editar* aparece mientras el domicilio no esté completado ni cancelado.
|
||||
Puede editar los domicilios **que agendó usted** y también **los que le asignaron**. El botón *Editar* aparece mientras el domicilio no esté completado ni cancelado.
|
||||
|
||||
## Avisar al paciente por WhatsApp
|
||||
|
||||
Hay un enlace que abre WhatsApp con el mensaje ya escrito, presentándote como profesional del Laboratorio Ximena Caicedo. Solo revisás y enviás.
|
||||
Hay un enlace que abre WhatsApp con el mensaje ya escrito, presentándote como profesional del Laboratorio Ximena Caicedo. Solo revisa y envía.
|
||||
|
||||
## Notas y archivos
|
||||
|
||||
Podés dejar dos tipos de nota en cada domicilio:
|
||||
Puede dejar dos tipos de nota en cada domicilio:
|
||||
|
||||
- **Nota de ficha** — estructurada, para datos clínicos.
|
||||
- **Nota libre** — texto suelto.
|
||||
@@ -38,29 +42,29 @@ Ambas admiten fotos y archivos adjuntos, útil para órdenes médicas en papel o
|
||||
|
||||
## Formularios
|
||||
|
||||
Podés enviarle un formulario al paciente para que lo firme desde su celular, o copiar el enlace para pasárselo por otro medio. Los que ya firmó se pueden consultar desde el mismo domicilio.
|
||||
Puede enviarle un formulario al paciente para que lo firme desde su celular, o copiar el enlace para pasárselo por otro medio. Los que ya firmó se pueden consultar desde el mismo domicilio.
|
||||
|
||||
Cuando firmás vos un formulario, queda registrado con tu **nombre y cédula**, tomados de tu ficha de enfermero.
|
||||
Cuando firma usted un formulario, queda registrado con su **nombre y cédula**, tomados de su ficha de enfermero.
|
||||
|
||||
## Estados de un domicilio
|
||||
|
||||
| Estado | Significado |
|
||||
|---|---|
|
||||
| Programado | Agendado, sin atender |
|
||||
| En curso | Estás en la visita |
|
||||
| En curso | Está en la visita |
|
||||
| Completado | Terminado |
|
||||
| Cancelado | No se hizo |
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**No puedo editar un domicilio.**
|
||||
Solo se pueden editar los propios o los asignados a vos, y solo si no está completado ni cancelado.
|
||||
Solo se pueden editar los propios o los asignados a usted, y solo si no está completado ni cancelado.
|
||||
|
||||
**El paciente cambió de dirección.**
|
||||
Editá el domicilio. Si el cambio es permanente, actualizá también la ficha del paciente.
|
||||
Edite el domicilio. Si el cambio es permanente, actualice también la ficha del paciente.
|
||||
|
||||
**Necesito reprogramar.**
|
||||
Editá la fecha y hora, y avisale al paciente por WhatsApp.
|
||||
Edite la fecha y hora, y avísele al paciente por WhatsApp.
|
||||
|
||||
**¿Puedo ver domicilios de otro enfermero?**
|
||||
No. El portal muestra únicamente los tuyos.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
roles: formularios_readonly, enfermero, supervisor
|
||||
---
|
||||
|
||||
# Formularios
|
||||
|
||||
Los consentimientos y fichas clínicas que firman pacientes y personal. Acá se diseñan, se consultan y se revisan los que ya se firmaron.
|
||||
|
||||
## Cómo está armado un formulario
|
||||
|
||||
Un formulario es una lista de campos en orden. El diseñador permite armarlo sin escribir nada técnico: se agregan campos, se les pone nombre y se ordenan.
|
||||
|
||||
| Tipo de campo | Para qué sirve |
|
||||
|---|---|
|
||||
| **Separador** | Divide el formulario en secciones |
|
||||
| **Párrafo** | Texto fijo — es lo que se usa para el articulado legal de un consentimiento |
|
||||
| **Texto**, **Área de texto**, **Número** | Respuestas escritas |
|
||||
| **Fecha**, **Hora** | Fechas y horas |
|
||||
| **Opción única**, **Casillas**, **Lista** | Respuestas para elegir |
|
||||
| **Vinculado** | Se llena solo con un dato del paciente (nombre, documento, EPS) |
|
||||
| **Firma del paciente** | La dibuja el paciente |
|
||||
| **Firma del profesional** | La dibuja quien atiende |
|
||||
|
||||
> Los campos **vinculados** evitan que alguien escriba mal un nombre o un documento: los toma de la ficha del paciente.
|
||||
|
||||
## Secciones que aparecen solo a veces
|
||||
|
||||
Una sección puede depender de una respuesta anterior. Por ejemplo, los datos obstétricos solo aparecen si el sexo registrado es femenino; las tomas de insulina, solo si se marcó ese examen.
|
||||
|
||||
Se configura eligiendo de qué campo depende la sección y con qué respuestas debe mostrarse. Si la condición no se cumple, ni la sección ni sus campos aparecen.
|
||||
|
||||
## Quién firma qué
|
||||
|
||||
```
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ FIRMA DEL PACIENTE │ │ FIRMA DEL PROFESIONAL │
|
||||
│ │ │ │
|
||||
│ la dibuja el paciente │ │ la dibuja quien atiende│
|
||||
│ desde su celular │ │ en el equipo del lab │
|
||||
│ │ │ │
|
||||
│ llega por WhatsApp │ │ queda con nombre │
|
||||
│ │ │ y cédula │
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
La firma del profesional **siempre** queda identificada con nombre y cédula. No es opcional.
|
||||
|
||||
## Formularios con varias firmas
|
||||
|
||||
Algunos formularios se firman por partes a lo largo de horas — el de tomas prolongadas es el caso típico. Cada firma se guarda por separado, con su hora y el nombre de quien la hizo, de modo que si cambia el turno del personal cada toma conserva el nombre correcto.
|
||||
|
||||
## Consultar formularios firmados
|
||||
|
||||
Se puede ver el documento tal como quedó, con las respuestas y las firmas. Los formularios firmados **no se modifican**: son un registro con valor legal.
|
||||
|
||||
Si un formulario tiene un error, se emite uno nuevo; no se corrige el anterior.
|
||||
|
||||
## Si solo tiene permiso de lectura
|
||||
|
||||
Puede consultar los formularios y los documentos firmados, pero no crear ni modificar plantillas. Es el permiso habitual para quien audita o revisa documentación.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**Cambié un formulario. ¿Se modifican los ya firmados?**
|
||||
No. Los firmados conservan la versión con la que se firmaron. El cambio aplica de ahí en adelante.
|
||||
|
||||
**Un documento firmado muestra secciones que no correspondían.**
|
||||
Avise a soporte con el número de turno. Debería mostrar únicamente las secciones del examen del paciente.
|
||||
|
||||
**No aparece el nombre de quien firmó.**
|
||||
Los documentos anteriores a agosto de 2026 no guardaban ese dato y no se puede recuperar. Los posteriores sí lo registran.
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
roles: supervisor
|
||||
---
|
||||
|
||||
# Administración
|
||||
|
||||
Tareas de administrador: usuarios, permisos, configuración y reportes.
|
||||
@@ -8,7 +12,7 @@ Tareas de administrador: usuarios, permisos, configuración y reportes.
|
||||
|
||||
La convención de la casa es usar el **número de cédula como nombre de usuario** para el personal asistencial.
|
||||
|
||||
Cargá también la **cédula** en su ficha: es lo que aparece bajo la firma en los formularios. Si falta, el documento sale firmado sin identificación.
|
||||
Cargue también la **cédula** en su ficha: es lo que aparece bajo la firma en los formularios. Si falta, el documento sale firmado sin identificación.
|
||||
|
||||
Los enfermeros son un caso aparte: su cédula sale de la ficha de enfermero, no del usuario.
|
||||
|
||||
@@ -16,7 +20,7 @@ Los enfermeros son un caso aparte: su cédula sale de la ficha de enfermero, no
|
||||
|
||||
> Un usuario tiene **dos** campos de rol y hay que cambiar los dos. El texto (`role`) es lo que muestra la interfaz; el vínculo (`role_id`) es de donde salen los permisos reales.
|
||||
|
||||
Si cambiás solo uno, el usuario ve un rol y tiene los permisos del otro. Ya pasó.
|
||||
Si cambia solo uno, el usuario ve un rol y tiene los permisos del otro. Ya pasó.
|
||||
|
||||
Después del cambio, **el usuario debe cerrar sesión y volver a entrar**: los permisos se cargan al iniciar sesión, no en cada pantalla.
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
roles: supervisor
|
||||
---
|
||||
|
||||
# Reportes y métricas
|
||||
|
||||
Dónde mirar cada cosa. Hay **dos lugares distintos** y conviene no confundirlos.
|
||||
|
||||
```
|
||||
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||
│ DASHBOARD DEL TURNERO │ │ REPORTES │
|
||||
│ │ │ │
|
||||
│ la operación de HOY │ │ el laboratorio en │
|
||||
│ │ │ general y los domicilios │
|
||||
│ • facturación del día │ │ │
|
||||
│ • tiempos de atención │ │ • ingresos y pagos │
|
||||
│ • turnos en espera │ │ • rendimiento │
|
||||
│ • asistente LIA │ │ • traslado de enfermeros │
|
||||
└──────────────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
## Facturación del día
|
||||
|
||||
En el dashboard del turnero, tres cifras separadas:
|
||||
|
||||
| Cifra | Qué incluye |
|
||||
|---|---|
|
||||
| **Facturado** | Turnos finalizados — plata efectivamente cobrada |
|
||||
| **En proceso** | Turnos aún activos, ya cobrados pero sin cerrar |
|
||||
| **Total estimado** | La suma de ambos |
|
||||
|
||||
Lo importante de entender: **los ausentes y cancelados no se cuentan en ninguna**. No se van a cobrar, así que sumarlos daría una cifra falsa.
|
||||
|
||||
La gráfica por hora usa la misma separación: azul lo finalizado, ámbar lo que sigue en curso.
|
||||
|
||||
## Tiempos de atención
|
||||
|
||||
El dashboard muestra promedios de espera, recepción y servicio, y los desglosa por persona.
|
||||
|
||||
En el historial, un servicio que superó los 20 minutos se resalta en rojo. **No siempre es un problema**: un examen seriado dura horas por definición. Al abrir el detalle se ve el desglose por toma y quién atendió cada una.
|
||||
|
||||
## Traslado de enfermeros
|
||||
|
||||
En Reportes. Sale de comparar las marcas de hora de las visitas domiciliarias, y sirve para detectar rutas mal armadas — mucho tiempo entre visitas suele significar que el orden geográfico no se tuvo en cuenta al agendar.
|
||||
|
||||
## Preguntar en lugar de buscar
|
||||
|
||||
En el dashboard del turnero está **LIA**, que responde sobre la operación del día:
|
||||
|
||||
- ¿Cuántos pacientes se atendieron hoy y cuántos faltan?
|
||||
- ¿Cuánto se facturó?
|
||||
- ¿Cuáles son los tiempos promedio por bacteriólogo?
|
||||
- ¿Cuántos están en espera ahora?
|
||||
|
||||
Responde con los datos del día, no con estimaciones. Si no tiene el dato, lo dice.
|
||||
|
||||
## Exportar
|
||||
|
||||
El historial de turnos se exporta a CSV con los filtros que tenga aplicados. Sirve para analizar en una planilla lo que la pantalla no muestra.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**Los números del dashboard y de reportes no coinciden.**
|
||||
Miran cosas distintas: el dashboard es la operación presencial de hoy; reportes abarca el laboratorio en general, incluidos domicilios. No son comparables directamente.
|
||||
|
||||
**Un turno aparece con un tiempo de servicio enorme.**
|
||||
Revise si era un examen seriado. Esos duran horas por diseño y el tiempo alto es correcto.
|
||||
|
||||
**Necesito un reporte que no existe.**
|
||||
Exporte el historial a CSV y arme el cálculo en una planilla. Si es algo que va a necesitar seguido, pídalo a un administrador.
|
||||
@@ -22,7 +22,7 @@ SELECT module_slug, permission FROM role_modules WHERE role_id = <role_id>;
|
||||
|
||||
Si los datos están bien → **que cierre sesión y vuelva a entrar**. Los permisos se cargan al iniciar sesión, no en cada petición.
|
||||
|
||||
Si `role` y `role_id` no coinciden, actualizá las dos:
|
||||
Si `role` y `role_id` no coinciden, actualice las dos:
|
||||
|
||||
```sql
|
||||
UPDATE admin_users
|
||||
@@ -37,13 +37,13 @@ UPDATE admin_users
|
||||
|
||||
## «Un enlace que enviamos por WhatsApp está roto»
|
||||
|
||||
Primero, comprobá si el destino responde:
|
||||
Primero, compruebe si el destino responde:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "<la URL>"
|
||||
```
|
||||
|
||||
**Si devuelve 503 o no resuelve**, el dominio está caído o cambió. Revisá si el archivo existe en el dominio actual del sistema.
|
||||
**Si devuelve 503 o no resuelve**, el dominio está caído o cambió. Revise si el archivo existe en el dominio actual del sistema.
|
||||
|
||||
Las URL que enviamos viven en lugares distintos según el caso:
|
||||
|
||||
@@ -71,7 +71,7 @@ UPDATE system_config
|
||||
|
||||
## «El mensaje salió por el número equivocado»
|
||||
|
||||
Las dos líneas comparten cuenta y token; lo único que cambia es el `phone_number_id`. Revisá que el envío haya especificado el canal:
|
||||
Las dos líneas comparten cuenta y token; lo único que cambia es el `phone_number_id`. Revise que el envío haya especificado el canal:
|
||||
|
||||
```php
|
||||
$wa = new WhatsAppService('turnero'); // no new WhatsAppService()
|
||||
@@ -103,8 +103,8 @@ UPDATE lab_config SET valor = '0' WHERE clave = 'lia_tokens_usados';
|
||||
|
||||
El formulario F-LAB-28 tiene secciones para todos los exámenes posibles y muestra solo las del examen del paciente. Si aparecen de más:
|
||||
|
||||
1. Verificá que el documento se abra con `&embed=1&compact=1` — sin esos parámetros no se aplica el filtrado.
|
||||
2. Revisá `_tomas_config` dentro de `datos_respuestas`: ahí queda qué ciclos se configuraron.
|
||||
1. Verifique que el documento se abra con `&embed=1&compact=1` — sin esos parámetros no se aplica el filtrado.
|
||||
2. Revise `_tomas_config` dentro de `datos_respuestas`: ahí queda qué ciclos se configuraron.
|
||||
|
||||
---
|
||||
|
||||
@@ -112,7 +112,7 @@ El formulario F-LAB-28 tiene secciones para todos los exámenes posibles y muest
|
||||
|
||||
Las firmas de tomas prolongadas registran el profesional **desde el 3 de agosto de 2026**. Los documentos firmados antes no tienen ese dato y **no es recuperable** — no quedó traza en ninguna tabla de auditoría.
|
||||
|
||||
Para los nuevos, el nombre y la cédula se resuelven en el servidor desde la sesión de quien firma. Si aparece vacío en un documento reciente, comprobá que el usuario tenga cédula:
|
||||
Para los nuevos, el nombre y la cédula se resuelven en el servidor desde la sesión de quien firma. Si aparece vacío en un documento reciente, compruebe que el usuario tenga cédula:
|
||||
|
||||
```sql
|
||||
SELECT id, username, full_name, cedula FROM admin_users WHERE id = <id>;
|
||||
|
||||
@@ -85,4 +85,4 @@ En **dos** lugares que hay que mantener sincronizados:
|
||||
|
||||
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql` e idempotentes (`IF NOT EXISTS`, guardas en `UPDATE`/`INSERT`).
|
||||
|
||||
Si aplicás un cambio directo en producción, **dejá también la migración**: sin ella, un entorno nuevo no tendrá ese cambio y nadie se va a enterar hasta que falle.
|
||||
Si aplica un cambio directo en producción, **deje también la migración**: sin ella, un entorno nuevo no tendrá ese cambio y nadie se va a enterar hasta que falle.
|
||||
|
||||
@@ -37,9 +37,9 @@ UPDATE admin_users SET cedula = username
|
||||
|
||||
MariaDB 11.8 admite `IF NOT EXISTS` en `ALTER TABLE`. Para `UPDATE` e `INSERT`, la guarda va en el `WHERE`.
|
||||
|
||||
Antes de dar por buena una migración, corrila dos veces y verificá que la segunda no cambie nada.
|
||||
Antes de dar por buena una migración, córrala dos veces y verifique que la segunda no cambie nada.
|
||||
|
||||
> Aplicar un cambio directo en producción sin dejar la migración hace que un entorno nuevo no lo tenga, y nadie se entera hasta que algo falla. Si tocás el esquema, dejá el archivo.
|
||||
> Aplicar un cambio directo en producción sin dejar la migración hace que un entorno nuevo no lo tenga, y nadie se entera hasta que algo falla. Si toca el esquema, deje el archivo.
|
||||
|
||||
## Archivos subidos
|
||||
|
||||
@@ -60,7 +60,7 @@ php -l archivo.php
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "https://<dominio>/<ruta>"
|
||||
```
|
||||
|
||||
Si el cambio afectó permisos, recordá que **las sesiones abiertas conservan los permisos viejos** hasta que el usuario vuelva a entrar.
|
||||
Si el cambio afectó permisos, recuerde que **las sesiones abiertas conservan los permisos viejos** hasta que el usuario vuelva a entrar.
|
||||
|
||||
## Configuración por entorno
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Respaldos y recuperación
|
||||
|
||||
Qué hay que respaldar, y qué se pierde si no está.
|
||||
|
||||
## Las tres cosas a respaldar
|
||||
|
||||
```
|
||||
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
|
||||
│ BASE DE DATOS │ │ ARCHIVOS SUBIDOS │ │ CÓDIGO │
|
||||
│ │ │ │ │ │
|
||||
│ 91 tablas │ │ uploads/terms/ │ │ repositorio git │
|
||||
│ pacientes, turnos, │ │ uploads/turnero/ │ │ │
|
||||
│ formularios, │ │ │ │ ya respaldado por │
|
||||
│ firmas │ │ NO están en git │ │ estar en el remoto │
|
||||
└────────────────────┘ └────────────────────┘ └────────────────────┘
|
||||
crítico crítico cubierto
|
||||
```
|
||||
|
||||
El código está a salvo por estar versionado. Los otros dos **no tienen respaldo automático por el solo hecho de existir**.
|
||||
|
||||
## Base de datos
|
||||
|
||||
Es lo único irreemplazable. Contiene historia clínica, consentimientos firmados y facturación — información con valor legal y sin forma de reconstruirse.
|
||||
|
||||
```bash
|
||||
mysqldump -h <host> -u <usuario> -p <base> \
|
||||
--single-transaction --routines --triggers \
|
||||
| gzip > respaldo_$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
`--single-transaction` evita bloquear las tablas mientras corre, así se puede hacer con el sistema en uso.
|
||||
|
||||
### Qué contiene lo crítico
|
||||
|
||||
| Tabla | Por qué importa |
|
||||
|---|---|
|
||||
| `lab_pacientes` | Fichas clínicas |
|
||||
| `turnero_consentimientos`, `lab_form_envios` | **Formularios firmados** — valor legal |
|
||||
| `turnero_turnos`, `turnero_solicitudes` | Historial de atención y facturación |
|
||||
| `terms_acceptance` | Aceptación de términos, ~5.000 registros |
|
||||
| `admin_users`, `roles`, `role_modules` | Acceso al sistema |
|
||||
|
||||
Las firmas se guardan como imagen **dentro** de las tablas, no como archivos sueltos. Un respaldo de la base las incluye.
|
||||
|
||||
## Archivos subidos
|
||||
|
||||
```
|
||||
uploads/terms/ documentos de términos y condiciones
|
||||
uploads/turnero/tv_media/ videos e imágenes de la pantalla de TV
|
||||
```
|
||||
|
||||
**No están en el repositorio.** Al mover el sistema de servidor hay que copiarlos aparte, o los enlaces quedan apuntando a archivos que ya no existen.
|
||||
|
||||
Es exactamente lo que pasó con el documento de términos cuando cambió el dominio: la base seguía apuntando a una URL que ya no respondía.
|
||||
|
||||
## Antes de un cambio riesgoso
|
||||
|
||||
Si va a tocar datos en producción, respalde **solo lo que va a tocar**:
|
||||
|
||||
```bash
|
||||
mysqldump -h <host> -u <usuario> -p <base> role_modules admin_users \
|
||||
> antes_del_cambio.sql
|
||||
```
|
||||
|
||||
Es rápido y suele alcanzar. Un respaldo completo para cambiar una columna es desproporcionado; no tener ninguno es imprudente.
|
||||
|
||||
## Verificar que el respaldo sirve
|
||||
|
||||
Un respaldo que nunca se restauró no es un respaldo, es un archivo:
|
||||
|
||||
```bash
|
||||
gunzip -t respaldo_2026-08-03.sql.gz # ¿está íntegro?
|
||||
zcat respaldo_2026-08-03.sql.gz | head -40 # ¿tiene lo que espera?
|
||||
```
|
||||
|
||||
Lo ideal es restaurarlo de vez en cuando en una base de prueba y comprobar que el sistema arranca contra ella.
|
||||
|
||||
## Qué NO es recuperable
|
||||
|
||||
Aunque tengas respaldos, hay datos que nunca se guardaron y no hay de dónde sacarlos:
|
||||
|
||||
| Dato | Desde cuándo existe |
|
||||
|---|---|
|
||||
| Quién creó cada consentimiento del turnero | 3 de agosto de 2026 |
|
||||
| Quién firmó cada toma de F-LAB-28 | 3 de agosto de 2026 |
|
||||
| Cédula del personal no enfermero | 3 de agosto de 2026 |
|
||||
|
||||
Los registros anteriores tienen esos campos vacíos, y **no hay traza de auditoría** que permita reconstruirlos. Vale como advertencia: cuando se agrega una columna para registrar quién hizo algo, lo anterior se pierde.
|
||||
@@ -54,6 +54,12 @@ $rawComps = [
|
||||
|
||||
Si el envío por plantilla falla, se manda un texto plano con el enlace armado desde el dominio del servidor. Ese texto **no** pasa por Meta, así que su URL puede diferir de la del botón.
|
||||
|
||||
## Contenido configurable
|
||||
|
||||
Buena parte de lo que responde el bot vive en la base, no en el código: respuestas
|
||||
automáticas por palabra clave y menús interactivos. Ver
|
||||
[Menús y respuestas automáticas](?m=soporte&v=documentacion&s=tecnica&d=bot-menus-y-respuestas).
|
||||
|
||||
## `BotService`
|
||||
|
||||
Decide qué hacer con cada mensaje entrante:
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Menús y respuestas automáticas del bot
|
||||
|
||||
Buena parte de lo que responde el bot **no está en el código**: es contenido configurable en la base. Cambiar lo que contesta no requiere tocar PHP ni desplegar.
|
||||
|
||||
## Respuestas automáticas
|
||||
|
||||
`autoresponses` — dispara una respuesta según lo que escriba el paciente.
|
||||
|
||||
| Columna | Para qué |
|
||||
|---|---|
|
||||
| `trigger_type` | Cómo se dispara: `welcome`, `keyword`, `contains` |
|
||||
| `trigger_value` | Las palabras que la activan, separadas por coma |
|
||||
| `response_text` | Lo que responde |
|
||||
| `response_type` | `text` o plantilla |
|
||||
| `template_name` | Plantilla de Meta, si aplica |
|
||||
| `menu_id` | Si en vez de texto debe mostrar un menú |
|
||||
| `priority` | Cuál gana si varias coinciden |
|
||||
| `is_active` | Permite desactivar sin borrar |
|
||||
|
||||
### Tipos de disparo
|
||||
|
||||
| Tipo | Cuándo actúa |
|
||||
|---|---|
|
||||
| `welcome` | Primer contacto |
|
||||
| `keyword` | El mensaje **es** una de las palabras listadas |
|
||||
| `contains` | El mensaje **contiene** el texto |
|
||||
|
||||
`keyword` es más estricto que `contains`. Para *«hola»* conviene `keyword` — si fuera `contains` se dispararía dentro de cualquier frase que la incluya.
|
||||
|
||||
### Las que están activas
|
||||
|
||||
Cubren saludo, precios, horarios, indicaciones previas, cotización y preparación para frotis. Son las preguntas que más se repiten, resueltas sin intervención humana.
|
||||
|
||||
## Menús interactivos
|
||||
|
||||
`menu_options` — el árbol de opciones numeradas que el paciente recorre respondiendo con un número.
|
||||
|
||||
| Columna | Para qué |
|
||||
|---|---|
|
||||
| `menu_id` | A qué menú pertenece la opción |
|
||||
| `option_number` | El número que marca el paciente |
|
||||
| `text` | Lo que se muestra |
|
||||
| `action_type` | Qué pasa al elegirla |
|
||||
| `action_value` | El destino o el texto de respuesta |
|
||||
| `is_active` | Permite ocultar sin borrar |
|
||||
|
||||
### Acciones
|
||||
|
||||
| `action_type` | Efecto |
|
||||
|---|---|
|
||||
| `menu` | Abre otro menú — `action_value` es su identificador |
|
||||
| `end` | Responde con `action_value` y cierra |
|
||||
| `message` | Envía el texto y sigue en el mismo menú |
|
||||
|
||||
### La estructura actual
|
||||
|
||||
```
|
||||
main_menu (1)
|
||||
├─ 1 Agendar toma a domicilio end
|
||||
├─ 2 Consultar resultados end
|
||||
├─ 3 Información sobre exámenes ─────► menu informacion_examenes (10)
|
||||
│ ├─ 1 Pruebas de embarazo end
|
||||
│ ├─ 2 Prueba de paternidad message
|
||||
│ ├─ 3 Pruebas de aliento end
|
||||
│ ├─ 4 Orina de 24 horas end
|
||||
│ ├─ 5 Solicitar cotización end
|
||||
│ └─ 6 Volver ──────────────────► main_menu
|
||||
├─ 4 Ubicación y horarios end
|
||||
├─ 5 Ver portafolio end
|
||||
├─ 6 Convenios end
|
||||
└─ 7 Salir end
|
||||
```
|
||||
|
||||
> Un menú se apunta por su **identificador** (`main_menu`, `informacion_examenes`), no por su número de fila. Cambiar el orden no rompe los enlaces.
|
||||
|
||||
## Estado de la conversación
|
||||
|
||||
`user_states` recuerda en qué punto quedó cada paciente: en qué menú está, si espera un dato, o si lo tomó un operador. Es lo que permite que responder «3» signifique algo.
|
||||
|
||||
Es la tabla más voluminosa del bot — una fila por contacto activo.
|
||||
|
||||
## Envíos masivos
|
||||
|
||||
`broadcast_history` registra los envíos a varios destinatarios a la vez.
|
||||
|
||||
> Fuera de la ventana de 24 horas hay que usar plantilla aprobada. Un envío masivo a contactos que no escribieron recientemente **solo puede hacerse con plantilla**.
|
||||
|
||||
## Encuestas
|
||||
|
||||
`survey_responses` guarda las respuestas de satisfacción. La encuesta se envía desde el historial del turnero con la plantilla `encuesta_turnero`.
|
||||
|
||||
## Al modificar el bot
|
||||
|
||||
- **Primero mire si alcanza con la base.** Muchos cambios de comportamiento son una fila en `autoresponses` o `menu_options`, sin desplegar nada.
|
||||
- **Use `is_active` en vez de borrar.** Permite volver atrás y conserva el historial.
|
||||
- **Cuide `priority`.** Si dos respuestas coinciden, gana la de mayor prioridad; sin ella el resultado depende del orden de la consulta.
|
||||
- **Verifique el árbol completo** después de tocar un menú: una opción que apunta a un menú inexistente deja al paciente sin salida.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Registro de exámenes
|
||||
|
||||
Registro de exámenes realizados en sede: creación de órdenes, toma de muestras e ingreso de resultados. Es de los módulos nuevos — todo su código vive dentro de `modules/registro_exams/`.
|
||||
|
||||
## Vistas
|
||||
|
||||
| Vista | Qué hace |
|
||||
|---|---|
|
||||
| `lista` | Órdenes con filtros y estado |
|
||||
| `nueva_orden` | Alta de una orden |
|
||||
| `orden` | Detalle: ítems, estados e ingreso de resultados |
|
||||
| `etiqueta` | Etiqueta imprimible para rotular la muestra |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Qué hace |
|
||||
|---|---|
|
||||
| `get_ordenes.php` | Lista con filtros |
|
||||
| `get_orden.php` | Detalle de una orden |
|
||||
| `save_orden.php` | Crea o actualiza |
|
||||
| `save_resultado.php` | Guarda el resultado de un ítem |
|
||||
| `cambiar_estado_item.php` | Avanza el estado de un ítem individual |
|
||||
|
||||
Usan `api/_helpers.php` del módulo, con las mismas convenciones que el turnero.
|
||||
|
||||
## Flujo
|
||||
|
||||
```
|
||||
nueva_orden orden orden
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌───────────┐ ┌─────────────┐
|
||||
│ crear ├──────►│ muestra ├──────────►│ resultado │
|
||||
│ orden │ │ tomada │ │ ingresado │
|
||||
└─────────┘ └───────────┘ └─────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
etiqueta se rotula
|
||||
imprimible la muestra
|
||||
```
|
||||
|
||||
El estado se lleva **por ítem**, no por orden completa: una orden puede tener unos exámenes resueltos y otros pendientes.
|
||||
|
||||
## Relación con otros módulos
|
||||
|
||||
| Módulo | Vínculo |
|
||||
|---|---|
|
||||
| `lab_examenes` | De ahí sale el catálogo y los valores de referencia |
|
||||
| `lab_pacientes` | El paciente de la orden |
|
||||
| `medicos` | El médico que la solicitó |
|
||||
|
||||
## Al tocarlo
|
||||
|
||||
Los resultados de laboratorio son información clínica: un ítem mal guardado o un valor de referencia equivocado tienen consecuencias reales. Cualquier cambio en `save_resultado.php` merece probarse con una orden de prueba antes de subirlo.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Órdenes médicas, reportes y configuración
|
||||
|
||||
Tres módulos de la generación anterior: el código vive en archivos de la raíz y el módulo solo aporta el registro en el menú.
|
||||
|
||||
| Módulo | Archivo real |
|
||||
|---|---|
|
||||
| `lab_ordenes` | `lab_ordenes.php` |
|
||||
| `lab_reportes` | `lab_reportes.php` |
|
||||
| `lab_configuracion` | `lab_configuracion.php` |
|
||||
| `lab_dashboard` | `index.php` |
|
||||
|
||||
## Órdenes médicas
|
||||
|
||||
Las órdenes que el paciente envía por WhatsApp, normalmente como foto de la orden en papel. El módulo permite revisarlas y autorizarlas.
|
||||
|
||||
### Datos
|
||||
|
||||
`lab_ordenes_medicas` guarda tanto la imagen recibida como lo que el personal extrae de ella:
|
||||
|
||||
| Grupo | Columnas |
|
||||
|---|---|
|
||||
| Origen | `conversation_id`, `whatsapp_media_id`, `local_file` |
|
||||
| Vínculos | `paciente_id`, `domicilio_id` |
|
||||
| Contenido de la orden | `medico_nombre`, `medico_registro`, `fecha_orden`, `diagnostico`, `examenes_solicitados` |
|
||||
| Preparación | `requiere_ayuno`, `horas_ayuno`, `indicaciones` |
|
||||
| Trazabilidad | `revisada_por`, `revisada_at`, `autorizada_por`, `autorizada_at`, `comentario_revision` |
|
||||
|
||||
### Estados
|
||||
|
||||
```
|
||||
pendiente ──► autorizada
|
||||
│
|
||||
└──────► rechazada
|
||||
(con comentario)
|
||||
```
|
||||
|
||||
La orden llega en `pendiente`. Al revisarla se autoriza o se rechaza, y en ambos casos queda **quién** lo hizo y **cuándo** — es de los pocos flujos que registró trazabilidad desde el principio.
|
||||
|
||||
`requiere_ayuno` y `horas_ayuno` importan porque condicionan lo que se le indica al paciente antes de la toma.
|
||||
|
||||
## Reportes
|
||||
|
||||
`lab_reportes.php` — informes de ingresos, rendimiento y pagos.
|
||||
|
||||
Incluye el **tiempo de traslado del enfermero**, que sale de comparar las marcas de hora de las visitas domiciliarias. Es la métrica que permite ver si una ruta está mal armada.
|
||||
|
||||
> Para métricas del turnero (tiempos de atención, facturación del día) el lugar es el **dashboard del turnero**, no este módulo. Los dos existen y cubren cosas distintas: este mira el laboratorio en general y los domicilios; aquel mira la operación presencial del día.
|
||||
|
||||
## Configuración del laboratorio
|
||||
|
||||
`lab_configuracion.php` edita lo que aparece en el encabezado de todos los documentos generados: nombre, subtítulo, dirección, ciudad, teléfono, correo, logo y color.
|
||||
|
||||
Se guarda en `lab_config`, con las claves `empresa_*` y `doc_*`. Ver [Configuraciones críticas](?m=soporte&v=documentacion&s=operacion&d=configuraciones-criticas) para el detalle de qué se configura desde aquí y qué no.
|
||||
|
||||
> Desde aquí **no** se cambian las URL que llegan por WhatsApp en los botones de plantilla: esas viven en Meta.
|
||||
|
||||
## Dashboard
|
||||
|
||||
`index.php` es el panel principal del laboratorio y, a la vez, el punto de entrada histórico del sistema. Convive con `erp.php`, que es el del ERP modular.
|
||||
|
||||
Que el archivo de entrada original siga siendo una pantalla activa es una de las consecuencias de la [migración gradual](?m=soporte&v=documentacion&s=arquitectura&d=decisiones-y-deuda).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Catálogos del laboratorio
|
||||
|
||||
Los módulos que administran las tablas maestras. Se agrupan acá porque comparten la misma forma: una pantalla de listado con alta, edición y baja sobre una tabla.
|
||||
|
||||
## Los módulos
|
||||
|
||||
| Módulo | Tabla principal | Qué administra |
|
||||
|---|---|---|
|
||||
| `lab_examenes` | `exam_tipos` | Catálogo de exámenes, valores de referencia y tarifas |
|
||||
| `medicos` | `medicos` | Médicos solicitantes |
|
||||
| `lab_pacientes` | `lab_pacientes` | Fichas clínicas e historial |
|
||||
| `lab_enfermeras` | `lab_enfermeras` | Personal clínico |
|
||||
| `lab_eps` | `lab_eps` | EPS y aseguradoras |
|
||||
| `lab_empresas` | `lab_empresas` | Empresas, convenios, subgrupos y tarifas |
|
||||
| `lab_ciudades` | `lab_ciudades` | Ciudades de pacientes |
|
||||
| `usuarios` | `admin_users`, `roles` | Usuarios y asignación de roles |
|
||||
|
||||
## Los que tienen endpoints propios
|
||||
|
||||
La mayoría son puentes al sistema anterior. Dos tienen código propio:
|
||||
|
||||
### `lab_examenes`
|
||||
|
||||
| Endpoint | Qué hace |
|
||||
|---|---|
|
||||
| `list.php` / `get.php` | Listado y detalle |
|
||||
| `save.php` | Alta y edición del examen |
|
||||
| `save_item.php` | Ítems que componen un examen |
|
||||
| `get_tarifas.php` / `save_tarifa.php` | Tarifas por empresa o convenio |
|
||||
|
||||
Es el catálogo del que dependen el turnero y el registro de exámenes. Un examen mal configurado se propaga a todo lo demás: consentimientos que no se piden, tarifas que no se aplican.
|
||||
|
||||
**`exam_tipo_consentimientos`** vincula un examen con los formularios que obliga a firmar. Es una de las dos fuentes de consentimientos del turnero; la otra es la estación destino.
|
||||
|
||||
### `medicos`
|
||||
|
||||
`list.php`, `save.php`, `delete.php`. El médico se asocia a la solicitud del turnero y sale impreso en los documentos.
|
||||
|
||||
## Pacientes
|
||||
|
||||
`lab_pacientes` es el más consultado de todos: lo usan el turnero, domicilios, registro de exámenes y el bot.
|
||||
|
||||
Campos que otros módulos dan por sentados:
|
||||
|
||||
| Campo | Quién lo usa |
|
||||
|---|---|
|
||||
| `numero_documento` | Búsqueda en todas las pantallas |
|
||||
| `telefono` | Envío de consentimientos y encuestas |
|
||||
| `nombre_completo`, `tipo_documento` | Encabezado de todos los documentos |
|
||||
| `fecha_nacimiento` | Cálculo de edad en formularios |
|
||||
| `eps` | Facturación |
|
||||
|
||||
Un teléfono mal cargado se manifiesta lejos de donde se originó: como un consentimiento que nunca llegó.
|
||||
|
||||
## Usuarios
|
||||
|
||||
Ver [Roles y permisos](?m=soporte&v=documentacion&s=arquitectura&d=roles-y-permisos) para el detalle del control de acceso.
|
||||
|
||||
Lo esencial al crear o editar un usuario:
|
||||
|
||||
- **`role` y `role_id` deben cambiarse juntos.** La interfaz lee uno, los permisos salen del otro.
|
||||
- **Cargue la cédula.** Es lo que aparece bajo la firma en los formularios.
|
||||
- El usuario debe **volver a iniciar sesión** para que un cambio de permisos surta efecto.
|
||||
|
||||
## Al agregar un catálogo nuevo
|
||||
|
||||
Si es un ABM simple, siga el patrón de `medicos`: un `views/index.php` y tres endpoints (`list`, `save`, `delete`). Registralo en `SYSTEM_MODULES` y concédalo a los roles que corresponda, o nadie lo verá.
|
||||
@@ -27,10 +27,10 @@ Cada módulo tiene su `api/_helpers.php` con lo común. Los archivos que empieza
|
||||
|
||||
### Reglas al agregar uno
|
||||
|
||||
1. **Verificá permisos en el propio endpoint.** Que la vista haya ocultado el botón no protege nada.
|
||||
2. **Resolvé la identidad en el servidor.** Para saber quién hace una acción, usá `adminId()`, no un valor que mande el navegador.
|
||||
1. **Verifique permisos en el propio endpoint.** Que la vista haya ocultado el botón no protege nada.
|
||||
2. **Resuelva la identidad en el servidor.** Para saber quién hace una acción, use `adminId()`, no un valor que mande el navegador.
|
||||
3. **Consultas preparadas siempre.**
|
||||
4. **Dejá un comentario de cabecera** describiendo qué hace y qué recibe: es lo que aparece en la tabla de abajo.
|
||||
4. **Deje un comentario de cabecera** describiendo qué hace y qué recibe: es lo que aparece en la tabla de abajo.
|
||||
|
||||
## Inventario
|
||||
|
||||
|
||||
@@ -5,23 +5,13 @@
|
||||
* de secciones (técnica, arquitectura, operación) solo para administradores.
|
||||
*/
|
||||
|
||||
$_sopEsAdmin = in_array($_SESSION['admin_user']['role'] ?? '', ['admin', 'superadmin'], true);
|
||||
|
||||
// Una sola entrada en el menú: la navegación por secciones y documentos vive
|
||||
// dentro de la propia página. Tener aquí un enlace por sección hacía que al
|
||||
// abrir una quedaran dos entradas marcadas como activas a la vez.
|
||||
$_sopLinks = [
|
||||
['name' => 'Documentación', 'icon' => 'fas fa-book', 'route' => '/erp.php?m=soporte&v=documentacion'],
|
||||
['name' => 'Manual de usuario', 'icon' => 'fas fa-book-reader',
|
||||
'route' => '/erp.php?m=soporte&v=documentacion&s=manual'],
|
||||
];
|
||||
|
||||
if ($_sopEsAdmin) {
|
||||
$_sopLinks[] = ['name' => 'Documentación técnica', 'icon' => 'fas fa-code',
|
||||
'route' => '/erp.php?m=soporte&v=documentacion&s=tecnica'];
|
||||
$_sopLinks[] = ['name' => 'Arquitectura', 'icon' => 'fas fa-sitemap',
|
||||
'route' => '/erp.php?m=soporte&v=documentacion&s=arquitectura'];
|
||||
$_sopLinks[] = ['name' => 'Operación y soporte', 'icon' => 'fas fa-life-ring',
|
||||
'route' => '/erp.php?m=soporte&v=documentacion&s=operacion'];
|
||||
}
|
||||
|
||||
return [
|
||||
'slug' => 'soporte',
|
||||
'name' => 'Soporte',
|
||||
|
||||
@@ -24,7 +24,8 @@ $cuerpo = '';
|
||||
$toc = [];
|
||||
|
||||
if ($archivo) {
|
||||
$md = Generadores::expandir((string)file_get_contents($archivo));
|
||||
[, $crudo] = DocIndex::leer($archivo); // descarta la cabecera de permisos
|
||||
$md = Generadores::expandir($crudo);
|
||||
$cuerpo = Markdown::render($md);
|
||||
$toc = Markdown::indice($md);
|
||||
foreach ($arbol[$seccion]['docs'] ?? [] as $d) {
|
||||
@@ -46,6 +47,10 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
|
||||
.doc-nav .buscador input { width:100%; font-size:.84rem; padding:7px 11px; border:1px solid #cbd5e1;
|
||||
border-radius:8px; background:#fff; }
|
||||
.doc-nav .buscador input:focus { outline:none; border-color:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.12); }
|
||||
.doc-nav .btn-pdf { display:block; margin-top:8px; text-align:center; font-size:.78rem;
|
||||
padding:6px 10px; border:1px solid #cbd5e1; border-radius:8px;
|
||||
color:#475569; text-decoration:none; background:#fff; }
|
||||
.doc-nav .btn-pdf:hover { border-color:#2563eb; color:#1d4ed8; }
|
||||
.doc-nav-sec { padding:10px 16px 4px; font-size:.68rem; font-weight:800; text-transform:uppercase;
|
||||
letter-spacing:.08em; color:#64748b; display:flex; align-items:center; gap:6px; }
|
||||
.doc-nav a { display:block; padding:5px 16px 5px 30px; font-size:.83rem; color:#334155;
|
||||
@@ -134,10 +139,85 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
|
||||
.doc-body { padding:20px 18px 60px; }
|
||||
}
|
||||
@media print {
|
||||
.doc-nav, .doc-toc, .doc-ruta { display:none !important; }
|
||||
.doc-nav, .doc-toc, .doc-ruta, #btnAI, #aiPanel { display:none !important; }
|
||||
.doc-body { max-width:none; padding:0; }
|
||||
.doc-body pre { background:#f8fafc; color:#0f172a; border:1px solid #cbd5e1; }
|
||||
}
|
||||
|
||||
/* ── Panel LIA — mismo diseño que el dashboard del turnero ── */
|
||||
#aiPanel {
|
||||
position:fixed; bottom:0; right:24px; width:400px; z-index:1080;
|
||||
box-shadow:0 -8px 40px rgba(0,0,0,.22); border-radius:20px 20px 0 0;
|
||||
background:#fff; border:1px solid #e2e8f0; border-bottom:none;
|
||||
transform:translateY(100%); transition:transform .35s cubic-bezier(.4,0,.2,1);
|
||||
display:flex; flex-direction:column; max-height:580px;
|
||||
}
|
||||
#aiPanel.open { transform:translateY(0); }
|
||||
#aiPanel.minimized .ai-body { display:none; }
|
||||
#aiPanel.minimized { max-height:none; }
|
||||
.ai-header {
|
||||
background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1);
|
||||
color:#fff; padding:14px 18px; border-radius:20px 20px 0 0;
|
||||
display:flex; align-items:center; gap:12px; flex-shrink:0; cursor:pointer; user-select:none;
|
||||
}
|
||||
.lia-logo {
|
||||
width:44px; height:44px; border-radius:50%;
|
||||
background:rgba(255,255,255,.15); border:2px solid rgba(255,255,255,.4);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:1rem; font-weight:900; letter-spacing:-1px; flex-shrink:0;
|
||||
transition:transform .3s;
|
||||
}
|
||||
.lia-logo:hover { transform:scale(1.08); }
|
||||
.lia-logo .lia-i { color:#7dd3fc; }
|
||||
.ai-title { font-weight:800; font-size:1.05rem; letter-spacing:.5px; }
|
||||
.btn-tts { background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3);
|
||||
color:rgba(255,255,255,.7); cursor:pointer; padding:5px 9px; border-radius:8px;
|
||||
font-size:.88rem; transition:all .2s; }
|
||||
.btn-tts:hover { background:rgba(255,255,255,.25); color:#fff; }
|
||||
.ai-header .btn-close { filter:invert(1) brightness(2); opacity:.7; }
|
||||
.ai-header .btn-close:hover { opacity:1; }
|
||||
.ai-body { display:flex; flex-direction:column; flex:1; overflow:hidden; }
|
||||
.ai-quick { display:flex; gap:7px; flex-wrap:wrap; padding:10px 14px;
|
||||
border-bottom:1px solid #f1f5f9; flex-shrink:0; background:#fafbff; }
|
||||
.ai-chip { background:#fff; border:1.5px solid #e2e8f0; border-radius:99px; padding:6px 13px;
|
||||
font-size:.82rem; cursor:pointer; color:#334155; white-space:nowrap;
|
||||
transition:all .15s; font-weight:500; }
|
||||
.ai-chip:hover { background:#eff6ff; border-color:#93c5fd; color:#1d4ed8; }
|
||||
.ai-messages { flex:1; overflow-y:auto; padding:14px 16px; display:flex; flex-direction:column; gap:10px; }
|
||||
.ai-msg { max-width:92%; padding:10px 14px; border-radius:16px; font-size:.9rem;
|
||||
line-height:1.55; animation:msg-in .2s ease; word-wrap:break-word; }
|
||||
@keyframes msg-in { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
|
||||
.ai-msg.user { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff;
|
||||
align-self:flex-end; border-radius:16px 16px 2px 16px; }
|
||||
.ai-msg.bot { background:#f1f5f9; color:#1e293b; align-self:flex-start; border-radius:16px 16px 16px 2px; }
|
||||
.ai-msg.bot.typing::after { content:'●●●'; animation:dots 1.2s infinite; letter-spacing:2px; }
|
||||
@keyframes dots { 0%,100%{opacity:.3} 50%{opacity:1} }
|
||||
.ai-msg.bot strong { color:#1565c0; }
|
||||
.ai-msg.bot ul, .ai-msg.bot ol { margin:4px 0 4px 16px; padding:0; }
|
||||
.ai-msg.bot code { background:#e2e8f0; color:#be185d; padding:1px 4px; border-radius:3px; font-size:.9em; }
|
||||
.ai-msg.bot pre { background:#0f172a; color:#e2e8f0; padding:9px 11px; border-radius:8px;
|
||||
overflow-x:auto; font-size:.76rem; margin:6px 0; }
|
||||
.ai-fuentes { font-size:.68rem; color:#94a3b8; align-self:flex-start; margin:-6px 0 0 4px; }
|
||||
.ai-footer { padding:10px 14px; border-top:1px solid #f1f5f9; display:flex; gap:8px;
|
||||
flex-shrink:0; background:#fafbff; }
|
||||
.ai-footer input { flex:1; border:1.5px solid #e2e8f0; border-radius:10px; padding:9px 14px;
|
||||
font-size:.9rem; outline:none; background:#fff; }
|
||||
.ai-footer input:focus { border-color:#1565c0; }
|
||||
.ai-footer .btn-send { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff; border:none;
|
||||
border-radius:10px; padding:9px 16px; cursor:pointer; font-size:.88rem; min-width:42px; }
|
||||
.ai-footer .btn-send:disabled { opacity:.5; cursor:default; }
|
||||
#btnAI {
|
||||
position:fixed; bottom:24px; right:24px; z-index:1079;
|
||||
background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1);
|
||||
color:#fff; border:none; border-radius:50%; width:58px; height:58px;
|
||||
font-size:.78rem; font-weight:900; letter-spacing:-.5px;
|
||||
cursor:pointer; box-shadow:0 4px 20px rgba(21,101,192,.45);
|
||||
transition:transform .2s,box-shadow .2s;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
#btnAI:hover { transform:scale(1.1); box-shadow:0 6px 28px rgba(21,101,192,.55); }
|
||||
#btnAI.hidden { display:none; }
|
||||
@media(max-width:600px) { #aiPanel { width:100%; right:0; border-radius:16px 16px 0 0; } }
|
||||
</style>
|
||||
|
||||
<div class="doc-wrap">
|
||||
@@ -145,6 +225,9 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
|
||||
<nav class="doc-nav">
|
||||
<div class="buscador">
|
||||
<input type="search" id="q" placeholder="Buscar en la documentación…" autocomplete="off">
|
||||
<a class="btn-pdf" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir" target="_blank">
|
||||
<i class="fas fa-file-pdf"></i> Exportar a PDF
|
||||
</a>
|
||||
</div>
|
||||
<div id="resultados" style="display:none"></div>
|
||||
<div id="arbol">
|
||||
@@ -231,6 +314,33 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Panel LIA — asistente sobre la documentación ── -->
|
||||
<button id="btnAI" onclick="toggleAI()" title="Preguntar a LIA">LIA</button>
|
||||
<div id="aiPanel">
|
||||
<div class="ai-header" onclick="minimizarIA(event)">
|
||||
<div class="lia-logo" id="liaLogo">
|
||||
<span>L</span><span class="lia-i">I</span><span>A</span>
|
||||
</div>
|
||||
<div style="flex:1;line-height:1.1">
|
||||
<div class="ai-title">LIA</div>
|
||||
<div style="font-size:.68rem;opacity:.75;font-weight:400">Asistente de Documentación</div>
|
||||
</div>
|
||||
<button class="btn-tts" onclick="event.stopPropagation();minimizarIA(event)" title="Minimizar" id="btnMin">
|
||||
<i class="fas fa-minus" id="iconMin"></i>
|
||||
</button>
|
||||
<button class="btn-close" onclick="event.stopPropagation();toggleAI()"></button>
|
||||
</div>
|
||||
<div class="ai-body" id="aiBody">
|
||||
<div class="ai-quick" id="aiQuick"></div>
|
||||
<div class="ai-messages" id="aiMessages"></div>
|
||||
<div class="ai-footer">
|
||||
<input type="text" id="aiInput" placeholder="Pregunta cómo hacer algo…" maxlength="400"
|
||||
onkeydown="if(event.key==='Enter')enviarIA()">
|
||||
<button class="btn-send" onclick="enviarIA()"><i class="fas fa-paper-plane"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── Buscador: índice completo servido con la página ── */
|
||||
const DOCS = <?= json_encode(DocIndex::indiceBusqueda(), JSON_UNESCAPED_UNICODE) ?>;
|
||||
@@ -280,6 +390,112 @@ function esc(s) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/* ── LIA: asistente sobre la documentación ──
|
||||
Mismo comportamiento y nombres que el panel del dashboard del turnero. */
|
||||
const AI_API = '<?= BASE_URL ?>modules/soporte/api/ai_docs.php';
|
||||
let _liaHistorial = [];
|
||||
|
||||
function toggleAI() {
|
||||
const panel = document.getElementById('aiPanel');
|
||||
const fab = document.getElementById('btnAI');
|
||||
const abre = !panel.classList.contains('open');
|
||||
panel.classList.toggle('open', abre);
|
||||
panel.classList.remove('minimized');
|
||||
fab.classList.toggle('hidden', abre);
|
||||
if (abre) {
|
||||
if (!document.getElementById('aiMessages').children.length) saludarIA();
|
||||
document.getElementById('aiInput').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function minimizarIA(e) {
|
||||
if (e) e.stopPropagation();
|
||||
const panel = document.getElementById('aiPanel');
|
||||
panel.classList.toggle('minimized');
|
||||
const icono = document.getElementById('iconMin');
|
||||
icono.className = panel.classList.contains('minimized') ? 'fas fa-chevron-up' : 'fas fa-minus';
|
||||
}
|
||||
|
||||
function saludarIA() {
|
||||
agregarMsg('bot', 'Hola, soy LIA. Pregúnteme cómo hacer algo en el sistema y le respondo con la documentación.');
|
||||
// Atajos armados con los documentos que este usuario realmente puede ver
|
||||
const quick = document.getElementById('aiQuick');
|
||||
quick.innerHTML = DOCS.slice(0, 4)
|
||||
.map(d => `<button class="ai-chip" onclick="enviarRapido('¿Qué explica ${esc(d.t)}?')">${esc(d.t)}</button>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function enviarRapido(texto) {
|
||||
document.getElementById('aiInput').value = texto;
|
||||
enviarIA();
|
||||
}
|
||||
|
||||
function agregarMsg(tipo, html) {
|
||||
const msgs = document.getElementById('aiMessages');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'ai-msg ' + tipo;
|
||||
div.innerHTML = html;
|
||||
msgs.appendChild(div);
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
/* Formato mínimo de la respuesta: negritas, código, listas y saltos. */
|
||||
function mdToHtml(md) {
|
||||
let h = esc(md);
|
||||
h = h.replace(/```[\s\S]*?```/g, m => '<pre>' + m.replace(/```\w*\n?/g, '') + '</pre>');
|
||||
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
h = h.replace(/^\s*[-*]\s+(.*)$/gm, '<li>$1</li>');
|
||||
h = h.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
|
||||
return h.replace(/\n{2,}/g, '<br><br>').replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
async function enviarIA() {
|
||||
const input = document.getElementById('aiInput');
|
||||
const pregunta = input.value.trim();
|
||||
if (!pregunta) return;
|
||||
|
||||
document.getElementById('aiQuick').innerHTML = '';
|
||||
agregarMsg('user', esc(pregunta));
|
||||
|
||||
const typing = agregarMsg('bot typing', '');
|
||||
input.value = '';
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(AI_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pregunta, historial: _liaHistorial }),
|
||||
});
|
||||
const d = await res.json();
|
||||
typing.className = 'ai-msg bot';
|
||||
|
||||
if (d.ok) {
|
||||
typing.innerHTML = mdToHtml(d.respuesta);
|
||||
if (d.fuentes && d.fuentes.length) {
|
||||
const f = document.createElement('div');
|
||||
f.className = 'ai-fuentes';
|
||||
f.textContent = 'Según: ' + d.fuentes.join(' · ');
|
||||
document.getElementById('aiMessages').appendChild(f);
|
||||
}
|
||||
_liaHistorial.push({ rol: 'user', texto: pregunta }, { rol: 'model', texto: d.respuesta });
|
||||
if (_liaHistorial.length > 6) _liaHistorial = _liaHistorial.slice(-6);
|
||||
} else {
|
||||
typing.textContent = d.error || 'No pude responder.';
|
||||
}
|
||||
} catch (_) {
|
||||
typing.className = 'ai-msg bot';
|
||||
typing.textContent = 'Error de conexión';
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
const msgs = document.getElementById('aiMessages');
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const inputQ = document.getElementById('q');
|
||||
let _t = null;
|
||||
inputQ.addEventListener('input', () => {
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/views/imprimir.php
|
||||
* Documentación completa en un solo documento, maquetado para imprimir a PDF.
|
||||
*
|
||||
* ?s=<seccion> limita la exportación a una sección
|
||||
* (sin parámetro) exporta todo lo que el usuario puede ver
|
||||
*
|
||||
* Se sirve sin el layout del ERP: la idea es que la página sea el entregable.
|
||||
*/
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
require_once __DIR__ . '/../Markdown.php';
|
||||
require_once __DIR__ . '/../DocIndex.php';
|
||||
require_once __DIR__ . '/../Generadores.php';
|
||||
|
||||
$arbol = DocIndex::arbol();
|
||||
|
||||
// Filtro opcional por sección
|
||||
$soloSeccion = preg_replace('/[^a-z0-9-]/', '', $_GET['s'] ?? '');
|
||||
if ($soloSeccion && isset($arbol[$soloSeccion])) {
|
||||
$arbol = [$soloSeccion => $arbol[$soloSeccion]];
|
||||
}
|
||||
|
||||
// Datos del laboratorio para la portada
|
||||
$emp = ['empresa_nombre' => 'Laboratorio Clínico', 'empresa_subtitulo' => '', 'doc_logo_base64' => ''];
|
||||
try {
|
||||
$rows = Database::getInstance()->getConnection()->query(
|
||||
"SELECT clave, valor FROM lab_config
|
||||
WHERE clave IN ('empresa_nombre','empresa_subtitulo','doc_logo_base64')"
|
||||
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$emp = array_merge($emp, $rows ?: []);
|
||||
} catch (\Throwable $_) {}
|
||||
|
||||
// Numeración: 1. Sección → 1.1 Documento
|
||||
$capitulos = [];
|
||||
$nSec = 0;
|
||||
foreach ($arbol as $sec => $cfg) {
|
||||
$nSec++;
|
||||
$docs = [];
|
||||
$nDoc = 0;
|
||||
foreach ($cfg['docs'] as $d) {
|
||||
$nDoc++;
|
||||
[, $cuerpo] = DocIndex::leer($d['archivo']);
|
||||
$md = Generadores::expandir($cuerpo);
|
||||
// El H1 del documento se reemplaza por el encabezado numerado
|
||||
$md = preg_replace('/^#\s+.*$/m', '', $md, 1);
|
||||
$docs[] = [
|
||||
'num' => "{$nSec}.{$nDoc}",
|
||||
'titulo' => $d['titulo'],
|
||||
'id' => 'doc-' . $sec . '-' . $d['slug'],
|
||||
'html' => Markdown::render($md),
|
||||
];
|
||||
}
|
||||
$capitulos[] = ['num' => (string)$nSec, 'titulo' => $cfg['titulo'],
|
||||
'resumen' => $cfg['resumen'], 'id' => 'cap-' . $sec, 'docs' => $docs];
|
||||
}
|
||||
|
||||
$totalDocs = array_sum(array_map(fn($c) => count($c['docs']), $capitulos));
|
||||
$esc = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Documentación del Sistema — <?= $esc($emp['empresa_nombre']) ?></title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 18mm 16mm 16mm 16mm;
|
||||
}
|
||||
@page :first { margin: 0; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
body {
|
||||
font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 10.5pt; line-height: 1.55; color: #1e293b;
|
||||
margin: 0; padding: 0; background: #fff;
|
||||
}
|
||||
|
||||
/* ── Barra de acciones (no se imprime) ── */
|
||||
.barra {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: #0f172a; color: #fff; padding: 11px 20px;
|
||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.barra .t { font-weight: 700; flex: 1; font-size: .95rem; }
|
||||
.barra button, .barra a {
|
||||
background: #2563eb; color: #fff; border: none; border-radius: 7px;
|
||||
padding: 7px 15px; font-size: .84rem; cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.barra a.sec { background: rgba(255,255,255,.14); }
|
||||
.barra button:hover { background: #1d4ed8; }
|
||||
.hoja { max-width: 195mm; margin: 0 auto; padding: 14mm 12mm 20mm; }
|
||||
|
||||
/* ── Portada ── */
|
||||
.portada {
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
min-height: 247mm; padding: 0 22mm; text-align: center;
|
||||
page-break-after: always; break-after: page;
|
||||
}
|
||||
.portada img { max-width: 170px; margin: 0 auto 26px; }
|
||||
.portada .lab { font-size: 13pt; color: #64748b; letter-spacing: .06em;
|
||||
text-transform: uppercase; margin-bottom: 6px; }
|
||||
.portada .sub { font-size: 10.5pt; color: #94a3b8; margin-bottom: 46px; }
|
||||
.portada h1 { font-size: 30pt; font-weight: 800; color: #0d47a1;
|
||||
margin: 0 0 10px; line-height: 1.15; letter-spacing: -.5px; }
|
||||
.portada .lin { width: 76px; height: 3px; background: #0d47a1;
|
||||
margin: 22px auto 24px; border-radius: 2px; }
|
||||
.portada .desc { font-size: 11pt; color: #475569; max-width: 118mm;
|
||||
margin: 0 auto 50px; line-height: 1.65; }
|
||||
.portada .meta { font-size: 9.5pt; color: #64748b; line-height: 1.9; }
|
||||
.portada .meta b { color: #334155; }
|
||||
|
||||
/* ── Índice ── */
|
||||
.indice { page-break-after: always; break-after: page; }
|
||||
.indice h2 { font-size: 17pt; color: #0d47a1; margin: 0 0 22px;
|
||||
padding-bottom: 10px; border-bottom: 2px solid #e2e8f0; }
|
||||
.idx-cap { margin-bottom: 16px; }
|
||||
.idx-cap > a { display: block; font-size: 11.5pt; font-weight: 700; color: #0f172a;
|
||||
text-decoration: none; margin-bottom: 3px; }
|
||||
.idx-cap .res { font-size: 9pt; color: #94a3b8; margin: 0 0 7px 20px; }
|
||||
.idx-doc { display: block; font-size: 10pt; color: #334155; text-decoration: none;
|
||||
margin: 0 0 3px 20px; }
|
||||
.idx-doc .n { color: #94a3b8; display: inline-block; min-width: 30px; }
|
||||
|
||||
/* ── Capítulo ── */
|
||||
.cap { page-break-before: always; break-before: page; padding-top: 26mm; text-align: center; }
|
||||
.cap .n { font-size: 46pt; font-weight: 800; color: #dbeafe; line-height: 1; }
|
||||
.cap h2 { font-size: 22pt; color: #0d47a1; margin: 6px 0 12px; font-weight: 800; }
|
||||
.cap p { color: #64748b; font-size: 10.5pt; max-width: 115mm; margin: 0 auto; }
|
||||
|
||||
/* ── Documento ── */
|
||||
.doc { page-break-before: always; break-before: page; }
|
||||
.doc-h {
|
||||
border-bottom: 2px solid #0d47a1; padding-bottom: 8px; margin-bottom: 16px;
|
||||
page-break-after: avoid; break-after: avoid;
|
||||
}
|
||||
.doc-h .n { font-size: 9pt; color: #2563eb; font-weight: 700; letter-spacing: .08em; }
|
||||
.doc-h h3 { font-size: 16pt; color: #0f172a; margin: 2px 0 0; font-weight: 700; }
|
||||
|
||||
h2, h3, h4 { page-break-after: avoid; break-after: avoid; }
|
||||
.doc h2 { font-size: 12.5pt; color: #1565c0; margin: 17px 0 7px; font-weight: 700; }
|
||||
.doc h3 { font-size: 11pt; color: #334155; margin: 13px 0 5px; font-weight: 700; }
|
||||
.doc h4 { font-size: 10pt; color: #475569; margin: 11px 0 4px; font-weight: 700; }
|
||||
.doc p { margin: 0 0 8px; text-align: justify; }
|
||||
.doc ul, .doc ol { margin: 0 0 9px; padding-left: 20px; }
|
||||
.doc li { margin-bottom: 2px; }
|
||||
.doc a { color: #1d4ed8; text-decoration: none; }
|
||||
.doc strong { color: #0f172a; }
|
||||
|
||||
code { background: #f1f5f9; color: #be185d; padding: 1px 4px; border-radius: 3px;
|
||||
font-family: "Consolas", "SF Mono", Menlo, monospace; font-size: .86em; }
|
||||
pre { background: #f8fafc; border: 1px solid #cbd5e1; border-left: 3px solid #1565c0;
|
||||
padding: 9px 12px; border-radius: 4px; margin: 0 0 10px; overflow-x: auto;
|
||||
page-break-inside: avoid; break-inside: avoid; }
|
||||
pre code { background: none; color: #0f172a; padding: 0; font-size: 8.2pt; line-height: 1.45; }
|
||||
|
||||
blockquote { border-left: 3px solid #f59e0b; background: #fffbeb; margin: 0 0 10px;
|
||||
padding: 8px 13px; page-break-inside: avoid; break-inside: avoid; }
|
||||
blockquote p { margin: 0; color: #78350f; font-size: 9.8pt; text-align: left; }
|
||||
|
||||
.tabla-scroll { margin: 0 0 11px; page-break-inside: avoid; break-inside: avoid; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 9pt; }
|
||||
thead { display: table-header-group; }
|
||||
th { background: #1565c0; color: #fff; text-align: left; font-weight: 600;
|
||||
padding: 5px 8px; border: 1px solid #1565c0; }
|
||||
td { padding: 4px 8px; border: 1px solid #e2e8f0; vertical-align: top; }
|
||||
tbody tr:nth-child(even) { background: #f8fafc; }
|
||||
|
||||
hr { border: none; border-top: 1px solid #e2e8f0; margin: 16px 0; }
|
||||
|
||||
/* Tablas muy largas (inventarios generados) pueden partirse entre páginas */
|
||||
.doc table { page-break-inside: auto; }
|
||||
tr { page-break-inside: avoid; break-inside: avoid; }
|
||||
|
||||
@media print {
|
||||
.barra { display: none !important; }
|
||||
.hoja { max-width: none; margin: 0; padding: 0; }
|
||||
a { color: #1e293b !important; text-decoration: none; }
|
||||
.portada { min-height: 250mm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="barra">
|
||||
<span class="t">
|
||||
<?= $totalDocs ?> documento<?= $totalDocs === 1 ? '' : 's' ?>
|
||||
<?= $soloSeccion ? '· ' . $esc($arbol[$soloSeccion]['titulo']) : '· documentación completa' ?>
|
||||
</span>
|
||||
<?php if ($soloSeccion): ?>
|
||||
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir">Exportar todo</a>
|
||||
<?php else: ?>
|
||||
<?php foreach (DocIndex::seccionesVisibles() as $s => $c): ?>
|
||||
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir&s=<?= urlencode($s) ?>">Solo <?= $esc($c['titulo']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Volver</a>
|
||||
<button onclick="window.print()">Imprimir / Guardar como PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="hoja">
|
||||
|
||||
<!-- ── Portada ── -->
|
||||
<div class="portada">
|
||||
<?php if (!empty($emp['doc_logo_base64'])): ?>
|
||||
<img src="<?= $esc($emp['doc_logo_base64']) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
<div class="lab"><?= $esc($emp['empresa_nombre']) ?></div>
|
||||
<?php if (!empty($emp['empresa_subtitulo'])): ?>
|
||||
<div class="sub"><?= $esc($emp['empresa_subtitulo']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h1>Documentación<br>del Sistema</h1>
|
||||
<div class="lin"></div>
|
||||
<p class="desc">
|
||||
<?php if ($soloSeccion): ?>
|
||||
<?= $esc($arbol[$soloSeccion]['resumen']) ?>
|
||||
<?php else: ?>
|
||||
Manual de usuario, documentación técnica, arquitectura y guía de operación
|
||||
del sistema de gestión del laboratorio.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<div class="meta">
|
||||
<?php if ($soloSeccion): ?><b><?= $esc($arbol[$soloSeccion]['titulo']) ?></b><br><?php endif; ?>
|
||||
<b><?= $totalDocs ?></b> documentos<br>
|
||||
Generado el <b><?= date('d/m/Y') ?></b><br>
|
||||
<span style="font-size:8.5pt">Los inventarios técnicos reflejan el estado del sistema a esta fecha</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Índice ── -->
|
||||
<div class="indice">
|
||||
<h2>Contenido</h2>
|
||||
<?php foreach ($capitulos as $cap): ?>
|
||||
<div class="idx-cap">
|
||||
<a href="#<?= $esc($cap['id']) ?>"><?= $esc($cap['num']) ?>. <?= $esc($cap['titulo']) ?></a>
|
||||
<p class="res"><?= $esc($cap['resumen']) ?></p>
|
||||
<?php foreach ($cap['docs'] as $d): ?>
|
||||
<a class="idx-doc" href="#<?= $esc($d['id']) ?>">
|
||||
<span class="n"><?= $esc($d['num']) ?></span><?= $esc($d['titulo']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- ── Contenido ── -->
|
||||
<?php foreach ($capitulos as $cap): ?>
|
||||
<div class="cap" id="<?= $esc($cap['id']) ?>">
|
||||
<div class="n"><?= $esc($cap['num']) ?></div>
|
||||
<h2><?= $esc($cap['titulo']) ?></h2>
|
||||
<p><?= $esc($cap['resumen']) ?></p>
|
||||
</div>
|
||||
<?php foreach ($cap['docs'] as $d): ?>
|
||||
<div class="doc" id="<?= $esc($d['id']) ?>">
|
||||
<div class="doc-h">
|
||||
<div class="n">CAPÍTULO <?= $esc($d['num']) ?></div>
|
||||
<h3><?= $esc($d['titulo']) ?></h3>
|
||||
</div>
|
||||
<?= $d['html'] ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/_acceso.php
|
||||
* Restricción por rol dentro del módulo Turnero.
|
||||
*
|
||||
* El control de acceso del ERP es por módulo: quien tiene `turnero` puede abrir
|
||||
* cualquiera de sus vistas escribiendo la URL, aunque el menú no se la muestre.
|
||||
* Para las pantallas que operan sobre la atención o cambian configuración eso no
|
||||
* alcanza, así que exigen además un rol.
|
||||
*
|
||||
* Uso, al inicio de la vista:
|
||||
* require_once __DIR__ . '/../_acceso.php';
|
||||
* turneroExigirRol(['admin', 'superadmin', 'supervisor']);
|
||||
*/
|
||||
|
||||
if (!function_exists('turneroExigirRol')) {
|
||||
/**
|
||||
* Corta la ejecución si el rol de la sesión no está entre los permitidos.
|
||||
* Los administradores pasan siempre.
|
||||
*/
|
||||
function turneroExigirRol(array $permitidos): void
|
||||
{
|
||||
$rol = $_SESSION['admin_user']['role'] ?? '';
|
||||
if (in_array($rol, ['admin', 'superadmin'], true)) return;
|
||||
if (in_array($rol, $permitidos, true)) return;
|
||||
|
||||
// Devolver a una pantalla que sí pueda ver, en vez de un error seco
|
||||
$destino = defined('BASE_URL') ? BASE_URL : '/';
|
||||
header('Location: ' . $destino . 'erp.php?m=turnero&v=dashboard&sin_acceso=1');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -182,16 +182,34 @@ if (!$ses) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Documentación relevante a la pregunta ─────────────────────
|
||||
// DocIndex filtra por rol: solo entra lo que este usuario podría leer por su
|
||||
// cuenta en el módulo Soporte, así LIA no puede revelar contenido restringido.
|
||||
$docCtx = '';
|
||||
try {
|
||||
require_once __DIR__ . '/../../soporte/DocIndex.php';
|
||||
[$docCtx] = DocIndex::contextoIA($pregunta);
|
||||
} catch (\Throwable $_) { /* sin documentación, LIA sigue respondiendo con los datos del día */ }
|
||||
|
||||
$bloqueDocs = $docCtx === '' ? '' : <<<DOCS
|
||||
|
||||
|
||||
MANUAL DE USUARIO (para preguntas de cómo se usa el sistema):
|
||||
{$docCtx}
|
||||
DOCS;
|
||||
|
||||
// ── Prompt ────────────────────────────────────────────────────
|
||||
$systemPrompt = <<<PROMPT
|
||||
Eres LIA, la asistente inteligente del sistema de turnero del Laboratorio Clínico.
|
||||
Responde en español, con el detalle que la pregunta requiera: si te piden un dato puntual sé breve, pero si te piden un listado, un análisis o una comparación, desarróllalo completo. Usa tablas o viñetas cuando ayuden a leer los datos.
|
||||
Responde en español, tratando SIEMPRE de usted (nunca tú ni vos), con el detalle que la pregunta requiera: si te piden un dato puntual sé breve, pero si te piden un listado, un análisis o una comparación, desarróllalo completo. Usa tablas o viñetas cuando ayuden a leer los datos.
|
||||
Puedes analizar: tiempos de espera y servicio por paciente, recepcionista o bacteriólogo; facturación del día; exámenes solicitados; franjas horarias con mayor demanda; buscar pacientes por nombre o cédula.
|
||||
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente.
|
||||
También responde preguntas de cómo se usa el sistema, apoyándose en el MANUAL que se incluye más abajo cuando esté presente.
|
||||
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente y no lo inventes.
|
||||
El manual incluido es el que este usuario tiene permitido consultar: no mencione ni deduzca contenido que no esté ahí.
|
||||
Tienes el historial de esta conversación: si el usuario pregunta algo que se refiere a tu respuesta anterior, respóndelo sin pedir que repita el contexto.
|
||||
|
||||
CONTEXTO DEL DÍA:
|
||||
{$ctx}
|
||||
{$ctx}{$bloqueDocs}
|
||||
PROMPT;
|
||||
|
||||
// ── Llamada a Gemini Flash ────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?q= — Busca diagnósticos CIE-10 por código o descripción.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
requireTurnero();
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -233,6 +234,27 @@ try {
|
||||
|
||||
notificarSSE((int) $turnoActualizado['sesion_id']);
|
||||
|
||||
// Queda constancia de quién cambió el estado a mano. Sin esto, en el
|
||||
// historial aparecía un turno cancelado o ausente sin ninguna huella
|
||||
// de quién lo hizo ni desde qué estado.
|
||||
try {
|
||||
(new ActividadAdmin())->registrar(
|
||||
adminId(),
|
||||
'turnero',
|
||||
'cambiar_estado',
|
||||
$turnoId,
|
||||
[
|
||||
'turno_id' => $turnoId,
|
||||
'turno_codigo' => $turnoActualizado['codigo'] ?? null,
|
||||
'estado_previo' => $estadoActual,
|
||||
'estado_nuevo' => $nuevoEstado,
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// La auditoría nunca debe tumbar la operación
|
||||
error_log('[cambiar_estado] auditoría: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Body: { consentimiento_id }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -30,4 +31,35 @@ $stmt->execute([$turnoId, $consentId]);
|
||||
|
||||
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
|
||||
|
||||
// Cancelar una toma pendiente cierra un protocolo que quedó a medias en otra
|
||||
// visita: conviene saber quién lo dio por terminado.
|
||||
try {
|
||||
$inf = $pdo->prepare(
|
||||
"SELECT tc.turno_id, t.codigo, f.nombre AS formulario
|
||||
FROM turnero_consentimientos tc
|
||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||
WHERE tc.id = ? LIMIT 1"
|
||||
);
|
||||
$inf->execute([$consentId]);
|
||||
$d = $inf->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
(new ActividadAdmin())->registrar(
|
||||
adminId(),
|
||||
'turnero',
|
||||
'cancelar_toma_pendiente',
|
||||
$consentId,
|
||||
[
|
||||
// Se registra bajo el turno donde se hizo la cancelación, que es
|
||||
// donde alguien la va a buscar en el historial.
|
||||
'turno_id' => $turnoId,
|
||||
'turno_origen_id' => isset($d['turno_id']) ? (int)$d['turno_id'] : null,
|
||||
'turno_origen' => $d['codigo'] ?? null,
|
||||
'formulario_nombre' => $d['formulario'] ?? null,
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[cancelar_toma_pendiente] auditoría: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonOk(['cancelado' => true]);
|
||||
|
||||
@@ -19,9 +19,8 @@ $svg = $body['svg'] ?? '';
|
||||
$soloPro = !empty($body['solo_profesional']);
|
||||
// Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
|
||||
$datosResp = null;
|
||||
if (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']) && !empty($body['datos_respuestas'])) {
|
||||
$datosResp = json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$datosArr = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
|
||||
? $body['datos_respuestas'] : [];
|
||||
|
||||
if (!$turnoId) jsonError('turno_id requerido.');
|
||||
if (!$formularioId) jsonError('formulario_id requerido.');
|
||||
@@ -32,6 +31,32 @@ if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// Quién firma se resuelve aquí y no se toma del navegador. El cliente manda el
|
||||
// nombre que cargó al abrir la página, y si el personal cambia de turno sin
|
||||
// recargar, la firma quedaba a nombre de quien abrió el formulario en vez de
|
||||
// quien la está haciendo. La sesión del servidor es la única fuente confiable.
|
||||
// (guardar_toma.php ya lo hacía así para cada toma; esto alinea la firma final.)
|
||||
$__uid = adminId();
|
||||
if ($__uid) {
|
||||
$stmtPro = $pdo->prepare(
|
||||
"SELECT au.full_name, au.cedula AS au_cedula,
|
||||
le.nombre_completo AS pro_nombre, le.numero_documento AS pro_documento
|
||||
FROM admin_users au
|
||||
LEFT JOIN lab_enfermeras le ON le.id = au.enfermera_id
|
||||
WHERE au.id = ? LIMIT 1"
|
||||
);
|
||||
$stmtPro->execute([$__uid]);
|
||||
$pro = $stmtPro->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$proNombre = ($pro['pro_nombre'] ?? null) ?: ($pro['full_name'] ?? null);
|
||||
$proCedula = ($pro['au_cedula'] ?? null) ?: ($pro['pro_documento'] ?? null);
|
||||
if ($proNombre) $datosArr['_pro_nombre'] = $proNombre;
|
||||
if ($proCedula) $datosArr['_pro_cedula'] = $proCedula;
|
||||
$datosArr['_pro_id'] = $__uid;
|
||||
}
|
||||
if (!empty($datosArr)) {
|
||||
$datosResp = json_encode($datosArr, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT tc.id, tc.estado, t.sesion_id
|
||||
FROM turnero_consentimientos tc
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
|
||||
* - desglose por prioridad
|
||||
* - desglose por lugar
|
||||
* - desglose por recepcionista y por bacteriólogo (quién atendió a cuántos)
|
||||
* - lista de turnos del día (para tabla detalle)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
@@ -41,10 +42,12 @@ if (!$sesion) {
|
||||
'tiempo_espera_promedio_min' => null,
|
||||
'tiempo_servicio_promedio_min' => null,
|
||||
],
|
||||
'por_prioridad' => [],
|
||||
'por_lugar' => [],
|
||||
'consent_stats' => [],
|
||||
'turnos' => [],
|
||||
'por_prioridad' => [],
|
||||
'por_lugar' => [],
|
||||
'por_recepcionista' => [],
|
||||
'por_bacteriologo' => [],
|
||||
'consent_stats' => [],
|
||||
'turnos' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -54,36 +57,51 @@ $sesionId = (int)$sesion['id'];
|
||||
$stmtRes = $pdo->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(estado IN ('finalizado','en_servicio')) AS atendidos,
|
||||
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
||||
SUM(estado = 'ausente') AS ausentes,
|
||||
SUM(estado = 'cancelado') AS cancelados,
|
||||
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
|
||||
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
||||
SUM(t.estado = 'ausente') AS ausentes,
|
||||
SUM(t.estado = 'cancelado') AS cancelados,
|
||||
ROUND(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0
|
||||
WHEN t.inicio_recepcion_at IS NOT NULL AND t.creado_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, t.creado_at, t.inicio_recepcion_at) / 60.0
|
||||
END
|
||||
), 1
|
||||
) AS tiempo_espera_promedio_min,
|
||||
-- En los protocolos prolongados la atención se corta en la PRIMERA toma:
|
||||
-- lo que sigue son horas de espera del examen (una curva de glucosa son
|
||||
-- 2 horas), no tiempo de trabajo. Contarlas triplicaba el promedio —21,9
|
||||
-- min contra 7,4— y hacía ver los puestos mucho más lentos de lo que son.
|
||||
ROUND(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
|
||||
WHEN t.inicio_lugar_at IS NOT NULL
|
||||
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||
END
|
||||
), 1
|
||||
) AS tiempo_servicio_promedio_min,
|
||||
ROUND(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60.0
|
||||
WHEN t.creado_at IS NOT NULL
|
||||
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, t.creado_at,
|
||||
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||
END
|
||||
), 1
|
||||
) AS tiempo_total_promedio_min
|
||||
FROM turnero_turnos
|
||||
WHERE sesion_id = ?"
|
||||
FROM turnero_turnos t
|
||||
-- Un turno tiene a lo sumo un protocolo prolongado, así que el join no duplica
|
||||
LEFT JOIN (
|
||||
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||
FROM turnero_consentimientos tc
|
||||
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||
GROUP BY tc.turno_id
|
||||
) prot ON prot.turno_id = t.id
|
||||
WHERE t.sesion_id = ?"
|
||||
);
|
||||
$stmtRes->execute([$sesionId]);
|
||||
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
|
||||
@@ -112,12 +130,20 @@ $stmtLug = $pdo->prepare(
|
||||
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
|
||||
ROUND(AVG(
|
||||
CASE
|
||||
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0
|
||||
WHEN t.inicio_lugar_at IS NOT NULL
|
||||
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||
END
|
||||
), 1) AS tiempo_servicio_promedio_min
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||
LEFT JOIN (
|
||||
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||
FROM turnero_consentimientos tc
|
||||
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||
GROUP BY tc.turno_id
|
||||
) prot ON prot.turno_id = t.id
|
||||
WHERE t.sesion_id = ?
|
||||
GROUP BY l.id
|
||||
ORDER BY l.sort_order ASC, l.nombre ASC"
|
||||
@@ -125,6 +151,63 @@ $stmtLug = $pdo->prepare(
|
||||
$stmtLug->execute([$sesionId]);
|
||||
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── 4b. Desglose por recepcionista ────────────────────────────
|
||||
// Mismo molde que el ranking de enfermeras de lab_domicilios
|
||||
// (api/lab/get_metricas.php). atendido_recepcion_por guarda al PRIMER
|
||||
// recepcionista que tomó el turno (COALESCE en cambiar_estado.php).
|
||||
$stmtRec = $pdo->prepare(
|
||||
"SELECT u.id,
|
||||
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
|
||||
COUNT(t.id) AS total,
|
||||
SUM(t.estado IN ('finalizado','en_servicio','en_espera_lugar')) AS atendidos,
|
||||
SUM(t.estado = 'ausente') AS ausentes,
|
||||
SUM(t.estado = 'cancelado') AS cancelados,
|
||||
ROUND(AVG(
|
||||
CASE
|
||||
WHEN t.fin_recepcion_at IS NOT NULL AND t.inicio_recepcion_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60.0
|
||||
END
|
||||
), 1) AS tiempo_promedio_min
|
||||
FROM turnero_turnos t
|
||||
JOIN admin_users u ON u.id = t.atendido_recepcion_por
|
||||
WHERE t.sesion_id = ?
|
||||
GROUP BY u.id, u.full_name, u.username
|
||||
ORDER BY atendidos DESC, total DESC"
|
||||
);
|
||||
$stmtRec->execute([$sesionId]);
|
||||
$porRecepcionista = $stmtRec->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── 4c. Desglose por bacteriólogo (quien atendió en el lugar) ──
|
||||
$stmtBac = $pdo->prepare(
|
||||
"SELECT u.id,
|
||||
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
|
||||
COUNT(t.id) AS total,
|
||||
SUM(t.estado = 'finalizado') AS finalizados,
|
||||
SUM(t.estado = 'en_servicio') AS en_servicio,
|
||||
SUM(t.estado = 'ausente') AS ausentes,
|
||||
ROUND(AVG(
|
||||
CASE
|
||||
WHEN t.inicio_lugar_at IS NOT NULL
|
||||
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||
END
|
||||
), 1) AS tiempo_promedio_min
|
||||
FROM turnero_turnos t
|
||||
JOIN admin_users u ON u.id = t.atendido_lugar_por
|
||||
LEFT JOIN (
|
||||
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||
FROM turnero_consentimientos tc
|
||||
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||
GROUP BY tc.turno_id
|
||||
) prot ON prot.turno_id = t.id
|
||||
WHERE t.sesion_id = ?
|
||||
GROUP BY u.id, u.full_name, u.username
|
||||
ORDER BY finalizados DESC, total DESC"
|
||||
);
|
||||
$stmtBac->execute([$sesionId]);
|
||||
$porBacteriologo = $stmtBac->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── 5. Lista de turnos del día ────────────────────────────────
|
||||
$stmtTurnos = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.numero, t.estado,
|
||||
@@ -157,22 +240,47 @@ $stmtTurnos = $pdo->prepare(
|
||||
$stmtTurnos->execute([$sesionId]);
|
||||
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Enriquecer con exámenes y comentarios
|
||||
foreach ($turnos as &$turno) {
|
||||
// Enriquecer con exámenes y comentarios — en lote.
|
||||
// Antes eran 2 consultas por turno dentro del foreach (N+1): con 80 turnos en
|
||||
// el día eran 160 consultas evitables. Mismo patrón que get_historial.php.
|
||||
$turnoIds = array_column($turnos, 'id');
|
||||
$examenesPorTurno = [];
|
||||
$comentariosPorTurno = [];
|
||||
|
||||
if ($turnoIds) {
|
||||
$ph = implode(',', array_fill(0, count($turnoIds), '?'));
|
||||
|
||||
$stmtEx = $pdo->prepare(
|
||||
"SELECT DISTINCT et.nombre FROM turnero_examen_items tei
|
||||
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||
WHERE tei.solicitud_id = (SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1)"
|
||||
"SELECT DISTINCT ts.turno_id, et.nombre
|
||||
FROM turnero_solicitudes ts
|
||||
JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
|
||||
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||
WHERE ts.turno_id IN ($ph)
|
||||
ORDER BY et.nombre ASC"
|
||||
);
|
||||
$stmtEx->execute([$turno['id']]);
|
||||
$turno['examenes'] = $stmtEx->fetchAll(PDO::FETCH_ASSOC);
|
||||
$stmtEx->execute($turnoIds);
|
||||
foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$examenesPorTurno[(int)$row['turno_id']][] = ['nombre' => $row['nombre']];
|
||||
}
|
||||
|
||||
$stmtCom = $pdo->prepare(
|
||||
"SELECT usuario_nombre, comentario, tipo, creado_at
|
||||
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at ASC"
|
||||
"SELECT turno_id, usuario_nombre, comentario, tipo, creado_at
|
||||
FROM turnero_comentarios
|
||||
WHERE turno_id IN ($ph)
|
||||
ORDER BY turno_id, creado_at ASC"
|
||||
);
|
||||
$stmtCom->execute([$turno['id']]);
|
||||
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
|
||||
$stmtCom->execute($turnoIds);
|
||||
foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$tid = (int)$row['turno_id'];
|
||||
unset($row['turno_id']);
|
||||
$comentariosPorTurno[$tid][] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($turnos as &$turno) {
|
||||
$tid = (int)$turno['id'];
|
||||
$turno['examenes'] = $examenesPorTurno[$tid] ?? [];
|
||||
$turno['comentarios'] = $comentariosPorTurno[$tid] ?? [];
|
||||
}
|
||||
unset($turno);
|
||||
|
||||
@@ -201,6 +309,8 @@ jsonOk([
|
||||
'resumen' => $resumen,
|
||||
'por_prioridad' => $porPrioridad,
|
||||
'por_lugar' => $porLugar,
|
||||
'por_recepcionista' => $porRecepcionista,
|
||||
'por_bacteriologo' => $porBacteriologo,
|
||||
'consent_stats' => $consentStats,
|
||||
'turnos' => $turnos,
|
||||
]);
|
||||
|
||||
@@ -76,6 +76,8 @@ if (empty($escritoriosRec)) {
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = 'en_recepcion'
|
||||
-- Misma regla que en los puestos: el turno vale hasta medianoche
|
||||
AND DATE(t.llamado_recepcion_at) = CURDATE()
|
||||
ORDER BY t.llamado_recepcion_at DESC"
|
||||
);
|
||||
$stmtRec->execute([$sesionId]);
|
||||
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = 'en_servicio'
|
||||
AND t.lugar_destino_id = ?
|
||||
-- Un turno vale hasta la medianoche de su día. Quedan bastantes sin
|
||||
-- cerrar, y sin este filtro el puesto mostraba como \"llamado ahora\"
|
||||
-- a un paciente de días atrás en cuanto se cerraba el turno real.
|
||||
AND DATE(t.llamado_lugar_at) = CURDATE()
|
||||
ORDER BY t.llamado_lugar_at DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /modules/turnero/api/get_firma_pendiente.php
|
||||
*
|
||||
* Le dice a la tablet del paciente qué mostrar: si hay alguien siendo atendido
|
||||
* en su puesto y le falta firmar el consentimiento de bienvenida (F-LAB-01).
|
||||
*
|
||||
* SIN SESIÓN DE OPERADOR, a propósito: esta tablet la manipula el público y
|
||||
* nadie va a iniciar sesión en ella cada mañana. Se identifica por la cookie
|
||||
* del dispositivo, que es un token de 64 caracteres registrado en Configuración.
|
||||
*
|
||||
* Por eso devuelve lo mínimo: el turno que está en ese puesto en este instante
|
||||
* y nada más. No permite consultar otros turnos, ni buscar, ni ver historial.
|
||||
* Sin dispositivo reconocido no responde nada.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
/** El único formulario que se firma en esta tablet: el de bienvenida. */
|
||||
const FORMULARIO_BIENVENIDA = 17;
|
||||
|
||||
function responder(array $datos): void {
|
||||
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
|
||||
// ── 1. ¿Qué puesto es esta tablet? ────────────────────────────────────────
|
||||
// Solo por token de navegador. La IP no sirve aquí: varias tablets salen por
|
||||
// la misma y acabaríamos mostrándole a un paciente los datos de otro puesto.
|
||||
$token = trim($_COOKIE['turnero_token'] ?? '');
|
||||
if ($token === '') {
|
||||
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT td.lugar_id, td.nombre AS dispositivo, tl.nombre AS lugar, tl.tipo
|
||||
FROM turnero_dispositivos td
|
||||
JOIN turnero_lugares tl ON tl.id = td.lugar_id
|
||||
WHERE td.token = ? AND td.activo = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$disp = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$disp || $disp['tipo'] !== 'recepcion') {
|
||||
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
|
||||
}
|
||||
|
||||
// ── 2. ¿Hay alguien siendo atendido ahí ahora? ────────────────────────────
|
||||
// Mismo criterio que la pantalla del televisor: el turno vale hasta la
|
||||
// medianoche de su día, para no mostrar a un paciente que ya se fue.
|
||||
// La sesión está abierta mientras fin_at siga en nulo; no hay columna de estado.
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.paciente_id, t.paciente_nombre
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_sesiones s ON s.id = t.sesion_id
|
||||
WHERE t.estado = 'en_recepcion'
|
||||
AND t.recepcion_desk_id = ?
|
||||
AND DATE(t.llamado_recepcion_at) = CURDATE()
|
||||
AND s.fin_at IS NULL
|
||||
ORDER BY t.llamado_recepcion_at DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([(int)$disp['lugar_id']]);
|
||||
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$turno) {
|
||||
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
|
||||
}
|
||||
|
||||
// Sin paciente vinculado no hay a quién atribuirle la firma
|
||||
if (empty($turno['paciente_id'])) {
|
||||
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
|
||||
}
|
||||
|
||||
// ── 3. ¿Le falta firmar el consentimiento de bienvenida? ──────────────────
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT token, estado FROM turnero_consentimientos
|
||||
WHERE turno_id = ? AND formulario_id = ?
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
|
||||
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($consent && in_array($consent['estado'], ['firmado', 'rechazado'], true)) {
|
||||
responder([
|
||||
'ok' => true,
|
||||
'estado' => 'firmado',
|
||||
'turno_id' => (int)$turno['id'],
|
||||
'codigo' => $turno['codigo'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Si el consentimiento todavía no existe se crea aquí. Es lo que permite que
|
||||
// la tablet aparezca sola, sin que la recepcionista tenga que mandarlo.
|
||||
if (!$consent) {
|
||||
$tokenFirma = sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
$ins = $pdo->prepare(
|
||||
"INSERT IGNORE INTO turnero_consentimientos
|
||||
(turno_id, formulario_id, token, estado, creado_por)
|
||||
VALUES (?, ?, ?, 'pendiente', NULL)"
|
||||
);
|
||||
$ins->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA, $tokenFirma]);
|
||||
|
||||
// INSERT IGNORE puede no haber insertado si otra petición se adelantó:
|
||||
// se relee para quedarse con el token que realmente quedó guardado.
|
||||
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
|
||||
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$consent) {
|
||||
responder(['ok' => false, 'motivo' => 'no_se_pudo_crear']);
|
||||
}
|
||||
}
|
||||
|
||||
responder([
|
||||
'ok' => true,
|
||||
'estado' => 'por_firmar',
|
||||
'turno_id' => (int)$turno['id'],
|
||||
'codigo' => $turno['codigo'],
|
||||
'paciente' => $turno['paciente_nombre'],
|
||||
'url' => BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($consent['token']),
|
||||
]);
|
||||
@@ -4,7 +4,7 @@
|
||||
* GET Historial paginado de turnos con filtros multi-día.
|
||||
*
|
||||
* Parámetros (todos opcionales):
|
||||
* fecha_desde YYYY-MM-DD (default: hace 7 días)
|
||||
* fecha_desde YYYY-MM-DD (default: hoy; hace 12 meses si se pasa paciente_id)
|
||||
* fecha_hasta YYYY-MM-DD (default: hoy)
|
||||
* estado string ("finalizado","ausente","cancelado","en_servicio",... o vacío = todos)
|
||||
* lugar_id int (0 = todos)
|
||||
@@ -23,16 +23,29 @@ $pdo = db();
|
||||
// ── Parámetros ────────────────────────────────────────────────
|
||||
$hoy = date('Y-m-d');
|
||||
|
||||
// paciente_id se lee primero: determina el rango de fechas por defecto.
|
||||
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
|
||||
|
||||
$fechaDesde = trim($_GET['fecha_desde'] ?? '');
|
||||
$fechaHasta = trim($_GET['fecha_hasta'] ?? '');
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $hoy;
|
||||
|
||||
// Rango por defecto cuando el cliente no manda fechas:
|
||||
// - Ficha de paciente (paciente_id): ultimos 12 meses.
|
||||
// Antes caia en "hoy", asi que el historial del paciente solo podia mostrar
|
||||
// visitas del mismo dia y siempre respondia "sin visitas anteriores".
|
||||
// - Listado general del historial: hoy (comportamiento original).
|
||||
// Para ver el historial completo, el cliente pasa fecha_desde explicita.
|
||||
$desdePorDefecto = $pacienteId > 0
|
||||
? date('Y-m-d', strtotime('-12 months'))
|
||||
: $hoy;
|
||||
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $desdePorDefecto;
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaHasta)) $fechaHasta = $hoy;
|
||||
if ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
|
||||
|
||||
$estadoFiltro = trim($_GET['estado'] ?? '');
|
||||
$lugarId = (int)($_GET['lugar_id'] ?? 0);
|
||||
$prioridadId = (int)($_GET['prioridad_id'] ?? 0);
|
||||
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
|
||||
$q = trim($_GET['q'] ?? '');
|
||||
|
||||
$perPage = min(200, max(10, (int)($_GET['per_page'] ?? 50)));
|
||||
@@ -183,13 +196,15 @@ if ($turnoIds) {
|
||||
"SELECT tc.turno_id, tc.usuario_nombre, tc.comentario, tc.tipo, tc.creado_at
|
||||
FROM turnero_comentarios tc
|
||||
WHERE tc.turno_id IN ($ph)
|
||||
ORDER BY tc.turno_id, tc.creado_at DESC"
|
||||
ORDER BY tc.turno_id, tc.creado_at ASC"
|
||||
);
|
||||
$stmtCom->execute($turnoIds);
|
||||
foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$tid = (int)$row['turno_id'];
|
||||
if (!isset($comentarios[$tid])) $comentarios[$tid] = [];
|
||||
if (count($comentarios[$tid]) < 3) {
|
||||
// Se traen todos: cortar en 3 escondía notas —entre ellas el motivo de
|
||||
// una ausencia— sin que nada indicara que faltaban.
|
||||
if (count($comentarios[$tid]) < 20) {
|
||||
unset($row['turno_id']);
|
||||
$comentarios[$tid][] = $row;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /modules/turnero/api/get_traza_turno.php?turno_id=N
|
||||
*
|
||||
* Bitácora de un turno: qué pasó, cuándo y quién lo hizo.
|
||||
*
|
||||
* La información ya existía, pero repartida en cinco tablas y sin ninguna
|
||||
* pantalla que la juntara: para saber quién retiró un formulario había que
|
||||
* consultar la base a mano. Aquí se unifica en una sola línea de tiempo.
|
||||
*
|
||||
* No inventa nada: cada evento sale de un dato guardado. Lo que el sistema no
|
||||
* registró —ver `sinRastro` más abajo— simplemente no aparece.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
requireTurnero();
|
||||
|
||||
$turnoId = (int)($_GET['turno_id'] ?? 0);
|
||||
if (!$turnoId) jsonError('turno_id requerido');
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$t = $pdo->prepare(
|
||||
"SELECT t.*, l.nombre AS lugar_nombre, d.nombre AS desk_nombre,
|
||||
ur.full_name AS recepcionista, ul.full_name AS profesional
|
||||
FROM turnero_turnos t
|
||||
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||
LEFT JOIN turnero_lugares d ON d.id = t.recepcion_desk_id
|
||||
LEFT JOIN admin_users ur ON ur.id = t.atendido_recepcion_por
|
||||
LEFT JOIN admin_users ul ON ul.id = t.atendido_lugar_por
|
||||
WHERE t.id = ? LIMIT 1"
|
||||
);
|
||||
$t->execute([$turnoId]);
|
||||
$turno = $t->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$turno) jsonError('Turno no encontrado', 404);
|
||||
|
||||
$ev = [];
|
||||
$add = function ($cuando, $tipo, $texto, $quien = null, $extra = null) use (&$ev) {
|
||||
if (!$cuando) return;
|
||||
$ev[] = [
|
||||
'cuando' => $cuando,
|
||||
'tipo' => $tipo, // la vista lo usa para el ícono y el color
|
||||
'texto' => $texto,
|
||||
'quien' => $quien ?: null,
|
||||
'extra' => $extra ?: null,
|
||||
];
|
||||
};
|
||||
|
||||
// ── 1. Recorrido del turno ────────────────────────────────────────────────
|
||||
$add($turno['creado_at'], 'turno', 'Turno creado');
|
||||
$add($turno['llamado_recepcion_at'], 'llamado', 'Llamado a ' . ($turno['desk_nombre'] ?: 'recepción'));
|
||||
$add($turno['inicio_recepcion_at'], 'atencion', 'Inicia atención en recepción', $turno['recepcionista']);
|
||||
$add($turno['fin_recepcion_at'], 'atencion', 'Termina recepción');
|
||||
$add($turno['llamado_lugar_at'], 'llamado', 'Llamado a ' . ($turno['lugar_nombre'] ?: 'toma de muestras'));
|
||||
$add($turno['inicio_lugar_at'], 'atencion', 'Inicia toma de muestras', $turno['profesional']);
|
||||
$add($turno['muestra_espera_at'], 'muestra', 'Pasa a espera de muestra');
|
||||
$add($turno['fin_lugar_at'], 'fin', 'Finaliza la atención');
|
||||
|
||||
// ── 2. Documentos ─────────────────────────────────────────────────────────
|
||||
$q = $pdo->prepare(
|
||||
"SELECT tc.enviado_at, tc.firmado_at, tc.firmado_profesional_at, tc.estado,
|
||||
tc.firma_svg, tc.firma_profesional_svg, tc.datos_respuestas,
|
||||
f.nombre AS formulario, f.es_toma_progresiva
|
||||
FROM turnero_consentimientos tc
|
||||
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||
WHERE tc.turno_id = ?"
|
||||
);
|
||||
$q->execute([$turnoId]);
|
||||
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $c) {
|
||||
$add($c['enviado_at'], 'doc', 'Enviado: ' . $c['formulario']);
|
||||
|
||||
// No todos los formularios los firma el paciente: el F-LAB-08 solo lo firma
|
||||
// el profesional y el F-LAB-28 lleva una firma por toma. Se mira qué firma
|
||||
// hay guardada en vez de suponer que firmado_at es del paciente, que era lo
|
||||
// que antes hacía decir «firmado por el paciente» en documentos que el
|
||||
// paciente nunca firmó.
|
||||
$firmoPaciente = !empty($c['firma_svg']);
|
||||
$firmoProfesional = !empty($c['firma_profesional_svg']);
|
||||
|
||||
if ($firmoPaciente) {
|
||||
$add($c['firmado_at'], 'doc_ok', 'Firmado por el paciente: ' . $c['formulario']);
|
||||
}
|
||||
if ($firmoProfesional) {
|
||||
$add($c['firmado_profesional_at'] ?: $c['firmado_at'], 'doc_ok',
|
||||
'Firmado por el profesional: ' . $c['formulario']);
|
||||
}
|
||||
|
||||
// Protocolo prolongado: cada toma lleva su hora y su firmante, que es el
|
||||
// dato que de verdad interesa. La firma general no dice quién pinchó.
|
||||
if (!empty($c['es_toma_progresiva'])) {
|
||||
$dr = json_decode($c['datos_respuestas'] ?? '{}', true) ?: [];
|
||||
$tomas = [];
|
||||
foreach ($dr as $clave => $valor) {
|
||||
// Las firmas de toma terminan en _f y traen su hora en _h
|
||||
if (!preg_match('/^(_tm[a-z0-9]+)_f$/', $clave, $m)) continue;
|
||||
if (!is_string($valor) || strlen($valor) < 10) continue;
|
||||
$base = $m[1];
|
||||
$tomas[] = [
|
||||
'hora' => $dr[$base . '_h'] ?? null,
|
||||
// _tm00 → «Minuto 0», _tm150 → «Minuto 150»
|
||||
'etiqueta' => 'Minuto ' . (int) substr($base, 3),
|
||||
'firmante' => $dr[$clave . '_pro_nombre'] ?? null,
|
||||
];
|
||||
}
|
||||
// Se ordenan por hora para que la línea de tiempo quede coherente
|
||||
usort($tomas, fn($a, $b) => strcmp((string)$a['hora'], (string)$b['hora']));
|
||||
foreach ($tomas as $t) {
|
||||
if (!$t['hora']) continue;
|
||||
// La hora viene como "HH:MM"; se ancla al día del turno
|
||||
$cuando = substr($turno['creado_at'], 0, 10) . ' ' . $t['hora'] . ':00';
|
||||
$add($cuando, 'muestra',
|
||||
'Toma tomada y firmada (' . $t['etiqueta'] . ')',
|
||||
$t['firmante']);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$firmoPaciente && !$firmoProfesional && $c['firmado_at'] && empty($c['es_toma_progresiva'])) {
|
||||
$add($c['firmado_at'], 'doc_ok', 'Completado: ' . $c['formulario']);
|
||||
}
|
||||
if (!empty($c['es_toma_progresiva']) && $c['firmado_at']) {
|
||||
$add($c['firmado_at'], 'fin', 'Protocolo cerrado: ' . $c['formulario']);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Comentarios del personal ───────────────────────────────────────────
|
||||
// Aquí caen también las ausencias, que se guardan como comentario con su motivo.
|
||||
$q = $pdo->prepare(
|
||||
"SELECT creado_at, usuario_nombre, comentario, tipo
|
||||
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at"
|
||||
);
|
||||
$q->execute([$turnoId]);
|
||||
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $c) {
|
||||
$add($c['creado_at'], 'comentario', $c['comentario'], $c['usuario_nombre'], $c['tipo']);
|
||||
}
|
||||
|
||||
// ── 4. Muestras ───────────────────────────────────────────────────────────
|
||||
$q = $pdo->prepare(
|
||||
"SELECT m.recibida_at, m.tipo_muestra, m.estado, m.motivo_rechazo,
|
||||
u.full_name AS quien
|
||||
FROM turnero_muestras m
|
||||
LEFT JOIN admin_users u ON u.id = m.recibida_por
|
||||
WHERE m.recibida_en_turno_id = ?"
|
||||
);
|
||||
$q->execute([$turnoId]);
|
||||
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $m) {
|
||||
$texto = $m['estado'] === 'rechazada'
|
||||
? 'Muestra RECHAZADA (' . $m['tipo_muestra'] . '): ' . ($m['motivo_rechazo'] ?: 'sin motivo')
|
||||
: 'Muestra recibida (' . $m['tipo_muestra'] . ')';
|
||||
$add($m['recibida_at'], $m['estado'] === 'rechazada' ? 'alerta' : 'muestra', $texto, $m['quien']);
|
||||
}
|
||||
|
||||
// ── 5. Acciones administrativas auditadas ─────────────────────────────────
|
||||
// El detalle es JSON; se filtra por turno en PHP para no depender de LIKE.
|
||||
$q = $pdo->prepare(
|
||||
"SELECT created_at, admin_nombre, accion, detalle
|
||||
FROM lab_actividad_admin
|
||||
WHERE modulo IN ('turnero','turnero_config') AND detalle LIKE ?"
|
||||
);
|
||||
$q->execute(['%"turno_id":' . $turnoId . '%']);
|
||||
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||
$d = json_decode($a['detalle'], true) ?: [];
|
||||
if ((int)($d['turno_id'] ?? 0) !== $turnoId) continue; // evita coincidencias por prefijo
|
||||
|
||||
// Cada acción se traduce a algo que se entienda leyéndolo, no al nombre
|
||||
// técnico del endpoint.
|
||||
switch ($a['accion']) {
|
||||
case 'quitar_consentimiento':
|
||||
$add($a['created_at'], 'alerta',
|
||||
'Formulario RETIRADO: ' . ($d['formulario_nombre'] ?? '?'),
|
||||
$a['admin_nombre'],
|
||||
'Motivo: ' . ($d['motivo'] ?? 'sin motivo')
|
||||
. ' · estaba en «' . ($d['estado_previo'] ?? '?') . '»');
|
||||
break;
|
||||
|
||||
case 'cambiar_estado':
|
||||
$add($a['created_at'], 'admin',
|
||||
'Estado cambiado a mano: ' . ($d['estado_previo'] ?? '?')
|
||||
. ' → ' . ($d['estado_nuevo'] ?? '?'),
|
||||
$a['admin_nombre']);
|
||||
break;
|
||||
|
||||
case 'resetear_consentimiento':
|
||||
$add($a['created_at'], 'alerta',
|
||||
'Formulario REINICIADO: ' . ($d['formulario_nombre'] ?? '?'),
|
||||
$a['admin_nombre'],
|
||||
!empty($d['tenia_firma'])
|
||||
? 'Se descartó una firma ya hecha'
|
||||
: 'No tenía firma todavía');
|
||||
break;
|
||||
|
||||
case 'resetear_toma':
|
||||
$n = is_array($d['campos_olvidados'] ?? null) ? count($d['campos_olvidados']) : 0;
|
||||
$add($a['created_at'], 'alerta',
|
||||
'Toma olvidada en ' . ($d['formulario_nombre'] ?? 'protocolo'),
|
||||
$a['admin_nombre'],
|
||||
$n ? "Se descartaron $n dato(s) de la toma" : null);
|
||||
break;
|
||||
|
||||
case 'cancelar_toma_pendiente':
|
||||
$add($a['created_at'], 'alerta',
|
||||
'Cancelada la toma pendiente de ' . ($d['turno_origen'] ?? 'otra visita'),
|
||||
$a['admin_nombre'],
|
||||
$d['formulario_nombre'] ?? null);
|
||||
break;
|
||||
|
||||
case 'llamar_turno':
|
||||
$add($a['created_at'], 'llamado', 'Llamó al paciente', $a['admin_nombre']);
|
||||
break;
|
||||
|
||||
case 'rellamar':
|
||||
$add($a['created_at'], 'llamado', 'Volvió a llamar al paciente', $a['admin_nombre']);
|
||||
break;
|
||||
|
||||
case 'vincular_paciente':
|
||||
$add($a['created_at'], 'admin',
|
||||
'Paciente vinculado: ' . ($d['paciente_nombre'] ?? '#' . ($d['paciente_id'] ?? '?')),
|
||||
$a['admin_nombre']);
|
||||
break;
|
||||
|
||||
default:
|
||||
$add($a['created_at'], 'admin', $a['accion'], $a['admin_nombre']);
|
||||
}
|
||||
}
|
||||
|
||||
usort($ev, fn($a, $b) => strcmp($a['cuando'], $b['cuando']));
|
||||
|
||||
// ── 6. Conversación de WhatsApp ───────────────────────────────────────────
|
||||
// Solo el conteo del día y el enlace al chat: volcar los mensajes aquí sería
|
||||
// esparcir datos personales del paciente por una pantalla de consulta.
|
||||
$chat = null;
|
||||
if (!empty($turno['paciente_id'])) {
|
||||
$q = $pdo->prepare(
|
||||
"SELECT u.id AS user_id,
|
||||
(SELECT COUNT(*) FROM conversations c
|
||||
WHERE c.user_id = u.id AND DATE(c.created_at) = DATE(?)) AS mensajes
|
||||
FROM lab_pacientes p JOIN users u ON u.id = p.user_id
|
||||
WHERE p.id = ? LIMIT 1"
|
||||
);
|
||||
$q->execute([$turno['creado_at'], (int)$turno['paciente_id']]);
|
||||
$r = $q->fetch(PDO::FETCH_ASSOC);
|
||||
if ($r && (int)$r['mensajes'] > 0) {
|
||||
$chat = ['user_id' => (int)$r['user_id'], 'mensajes' => (int)$r['mensajes']];
|
||||
}
|
||||
}
|
||||
|
||||
jsonOk([
|
||||
'turno' => [
|
||||
'id' => (int)$turno['id'],
|
||||
'codigo' => $turno['codigo'],
|
||||
'estado' => $turno['estado'],
|
||||
'paciente' => $turno['paciente_nombre'],
|
||||
],
|
||||
'eventos' => $ev,
|
||||
'chat' => $chat,
|
||||
// Se declara lo que el sistema NO registra, para que nadie lea la ausencia
|
||||
// de un evento como prueba de que no ocurrió.
|
||||
// Lo que el sistema sigue sin registrar. Se declara para que la ausencia de
|
||||
// un evento no se lea como prueba de que no ocurrió.
|
||||
'sinRastro' => [
|
||||
'el llamado original de los turnos anteriores a hoy, que el rellamado sobrescribía',
|
||||
],
|
||||
]);
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -144,6 +145,20 @@ try {
|
||||
$pdo->commit();
|
||||
notificarSSE((int) $turnoActualizado['sesion_id']);
|
||||
|
||||
// Quién llamó no se guardaba en ninguna parte: el turno solo conserva la hora
|
||||
// del último llamado, así que en la bitácora aparecía «Llamado a X» sin autor.
|
||||
try {
|
||||
(new ActividadAdmin())->registrar(
|
||||
adminId(), 'turnero', 'llamar_turno', $turnoId,
|
||||
[
|
||||
'turno_id' => $turnoId,
|
||||
'turno_codigo' => $turnoActualizado['codigo'] ?? null,
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[llamar_turno] auditoría: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /modules/turnero/api/log_tv.php
|
||||
* Recibe la bitácora de voz de la pantalla del televisor (via sendBeacon).
|
||||
*
|
||||
* SIN SESIÓN, a propósito: la pantalla es un kiosco público (display_global es
|
||||
* ruta pública) y el envío es fire-and-forget. Por eso el endpoint es
|
||||
* deliberadamente sordo y estrecho:
|
||||
* - solo acepta los eventos de la lista, nada libre;
|
||||
* - recorta el detalle a 500 caracteres;
|
||||
* - no devuelve datos, solo un 204;
|
||||
* - inserta y nada más: no lee, no borra, no actualiza.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
http_response_code(204);
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') exit;
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || strlen($raw) > 2000) exit;
|
||||
|
||||
$d = json_decode($raw, true);
|
||||
if (!is_array($d)) exit;
|
||||
|
||||
$evento = $d['e'] ?? '';
|
||||
$PERMITIDOS = ['start', 'end', 'error', 'sin_start', 'mudo', 'lag'];
|
||||
if (!in_array($evento, $PERMITIDOS, true)) exit;
|
||||
|
||||
unset($d['e'], $d['t']); // el timestamp lo pone la base, no el cliente
|
||||
|
||||
try {
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO turnero_tv_log (evento, detalle, ip, user_agent) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([
|
||||
$evento,
|
||||
mb_substr(json_encode($d, JSON_UNESCAPED_UNICODE), 0, 500),
|
||||
substr(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]), 0, 45),
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// La bitácora jamás debe afectar a la pantalla
|
||||
error_log('[log_tv] ' . $e->getMessage());
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -48,4 +49,15 @@ $stmtT = $pdo->prepare(
|
||||
$stmtT->execute([$turnoId]);
|
||||
$turno = $stmtT->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// El rellamado pisa llamado_recepcion_at, así que sin esto no queda rastro de
|
||||
// cuántas veces se llamó al paciente ni quién lo hizo.
|
||||
try {
|
||||
(new ActividadAdmin())->registrar(
|
||||
adminId(), 'turnero', 'rellamar', $turnoId,
|
||||
['turno_id' => $turnoId, 'turno_codigo' => $turno['codigo'] ?? null]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[rellamar] auditoría: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonOk(['turno' => $turno]);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Body JSON: { token: string }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -14,9 +15,11 @@ if (!$token) jsonError('token requerido.');
|
||||
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT tc.id, t.sesion_id
|
||||
"SELECT tc.id, tc.turno_id, tc.estado, tc.firmado_at, t.sesion_id, t.codigo,
|
||||
f.nombre AS formulario
|
||||
FROM turnero_consentimientos tc
|
||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||
WHERE tc.token = ? LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
@@ -34,4 +37,25 @@ $pdo->prepare(
|
||||
)->execute([$token]);
|
||||
|
||||
notificarSSE((int)$tc['sesion_id']);
|
||||
|
||||
// Esta acción descarta una firma ya hecha, así que tiene que dejar constancia:
|
||||
// se anota qué documento era, en qué estado estaba y si tenía firma.
|
||||
try {
|
||||
(new ActividadAdmin())->registrar(
|
||||
adminId(),
|
||||
'turnero',
|
||||
'resetear_consentimiento',
|
||||
(int)$tc['id'],
|
||||
[
|
||||
'turno_id' => (int)$tc['turno_id'],
|
||||
'turno_codigo' => $tc['codigo'],
|
||||
'formulario_nombre' => $tc['formulario'],
|
||||
'estado_previo' => $tc['estado'],
|
||||
'tenia_firma' => !empty($tc['firmado_at']),
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[resetear_consentimiento] auditoría: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonOk([], 'Consentimiento reiniciado correctamente.');
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Body JSON: { token: string, campos: string[] }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -17,9 +18,11 @@ if (!is_array($campos) || empty($campos)) jsonError('campos requerido.');
|
||||
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT tc.id, tc.datos_respuestas, tc.estado, t.sesion_id
|
||||
"SELECT tc.id, tc.turno_id, tc.datos_respuestas, tc.estado, t.sesion_id, t.codigo,
|
||||
f.nombre AS formulario
|
||||
FROM turnero_consentimientos tc
|
||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||
WHERE tc.token = ? LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
@@ -40,4 +43,24 @@ $pdo->prepare(
|
||||
)->execute([json_encode($dr, JSON_UNESCAPED_UNICODE), $token]);
|
||||
|
||||
notificarSSE((int)$tc['sesion_id']);
|
||||
|
||||
// Olvidar una toma borra datos de un protocolo prolongado en curso: se registra
|
||||
// qué campos se descartaron, porque después no hay forma de saberlo.
|
||||
try {
|
||||
(new ActividadAdmin())->registrar(
|
||||
adminId(),
|
||||
'turnero',
|
||||
'resetear_toma',
|
||||
(int)$tc['id'],
|
||||
[
|
||||
'turno_id' => (int)$tc['turno_id'],
|
||||
'turno_codigo' => $tc['codigo'],
|
||||
'formulario_nombre' => $tc['formulario'],
|
||||
'campos_olvidados' => $campos,
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[resetear_toma] auditoría: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonOk([], 'Toma olvidada.');
|
||||
|
||||
@@ -32,6 +32,13 @@ header('Cache-Control: no-store, no-cache');
|
||||
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
|
||||
header('Connection: keep-alive');
|
||||
|
||||
// El arranque del ERP deja un búfer de salida activo (los jsonOk() de
|
||||
// _helpers hacen ob_clean(), que lo confirma). flush() a secas no lo
|
||||
// atraviesa: TODO lo emitido quedaba atrapado y el cliente recibía cero
|
||||
// bytes, para siempre. Por eso este endpoint parecía muerto en producción.
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
@ob_implicit_flush(true);
|
||||
|
||||
// ── Parámetros ────────────────────────────────────────────────
|
||||
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
|
||||
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Vincula un paciente al turno para que los consentimientos puedan crearse.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -27,4 +28,22 @@ $stmt->execute([$pacienteId, $nombreCompleto, $turnoId]);
|
||||
|
||||
if (!$stmt->rowCount()) jsonError('Turno no encontrado.', 404);
|
||||
|
||||
// Vincular a la persona equivocada manda la atención a otra historia clínica,
|
||||
// así que queda registrado quién hizo la vinculación.
|
||||
try {
|
||||
(new ActividadAdmin())->registrar(
|
||||
adminId(),
|
||||
'turnero',
|
||||
'vincular_paciente',
|
||||
$turnoId,
|
||||
[
|
||||
'turno_id' => $turnoId,
|
||||
'paciente_id' => $pacienteId,
|
||||
'paciente_nombre' => $nombreCompleto,
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[vincular_paciente] auditoría: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonOk(['turno_id' => $turnoId, 'paciente_id' => $pacienteId]);
|
||||
|
||||
+17
-11
@@ -9,6 +9,7 @@ $_trAdminRoles = ['superadmin', 'admin', 'supervisor'];
|
||||
$_trIsAdmin = in_array($_trRole, $_trAdminRoles, true);
|
||||
$_trIsRecep = in_array($_trRole, ['recepcionista', 'lab_recepcion'], true);
|
||||
$_trIsBacte = $_trRole === 'bacteriologo';
|
||||
$_trIsCalidad = $_trRole === 'calidad';
|
||||
$_trClientIp = trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]);
|
||||
|
||||
try {
|
||||
@@ -19,7 +20,7 @@ try {
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$_trMuestras = $_trPdo->query(
|
||||
"SELECT id, nombre FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order"
|
||||
"SELECT id, nombre, acceso_libre FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (\Throwable $_) {
|
||||
$_trDesks = [];
|
||||
@@ -28,8 +29,13 @@ try {
|
||||
|
||||
$_trLinks = [];
|
||||
|
||||
// ── Calidad: solo consulta de turnos y tiempos ────────────────
|
||||
if ($_trIsCalidad) {
|
||||
$_trLinks[] = ['name' => 'Dashboard', 'icon' => 'fas fa-tachometer-alt', 'route' => '/erp.php?m=turnero&v=dashboard'];
|
||||
$_trLinks[] = ['name' => 'Historial', 'icon' => 'fas fa-history', 'route' => '/erp.php?m=turnero&v=historial'];
|
||||
|
||||
// ── Recepcionista: pantallas comunes + desks según IP ─────────
|
||||
if ($_trIsRecep) {
|
||||
} elseif ($_trIsRecep) {
|
||||
$_trLinks[] = ['name' => 'Chat Turnero', 'icon' => 'fab fa-whatsapp', 'route' => '/erp.php?m=turnero&v=chat'];
|
||||
$_trLinks[] = ['name' => 'Verificar Paciente', 'icon' => 'fas fa-id-card', 'route' => '/erp.php?m=turnero&v=verificar_paciente'];
|
||||
$_trLinks[] = ['name' => 'Pantalla TV', 'icon' => 'fas fa-tv', 'route' => '/erp.php?m=turnero&v=display_global'];
|
||||
@@ -108,16 +114,16 @@ if ($_trIsRecep) {
|
||||
$_trLinks[] = ['name' => 'Bandeja del día', 'icon' => 'fas fa-layer-group', 'route' => '/erp.php?m=turnero&v=bandeja'];
|
||||
|
||||
if ($_trIpLugar) {
|
||||
// IP registrada: solo ese lugar en el sidebar
|
||||
// IP registrada: su estación, más las marcadas como de acceso libre
|
||||
// (Pediatría, Ginecología), que se atienden desde cualquier puesto.
|
||||
foreach ($_trMuestras as $_m) {
|
||||
if ((int)$_m['id'] === (int)$_trIpLugar) {
|
||||
$_trLinks[] = [
|
||||
'name' => $_m['nombre'],
|
||||
'icon' => 'fas fa-flask',
|
||||
'route' => '/erp.php?m=turnero&v=lugar&lugar_id=' . (int)$_m['id'],
|
||||
];
|
||||
break;
|
||||
}
|
||||
$_esSuyo = (int)$_m['id'] === (int)$_trIpLugar;
|
||||
if (!$_esSuyo && empty($_m['acceso_libre'])) continue;
|
||||
$_trLinks[] = [
|
||||
'name' => $_m['nombre'],
|
||||
'icon' => $_esSuyo ? 'fas fa-flask' : 'fas fa-share-square',
|
||||
'route' => '/erp.php?m=turnero&v=lugar&lugar_id=' . (int)$_m['id'],
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// IP no registrada: TV + todos los lugares muestras
|
||||
|
||||
@@ -251,6 +251,9 @@ Layout::open('Bandeja del día', 'fas fa-layer-group');
|
||||
.bnd-com-user { font-weight: 600; color: #1e293b; }
|
||||
.bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; }
|
||||
.bnd-com-text { color: #334155; }
|
||||
.bnd-traza { border-left:2px solid #e2e8f0; margin-left:4px; padding-left:10px; }
|
||||
.bnd-traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.8rem; color:#334155; }
|
||||
.bnd-traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
|
||||
/* Agregar comentario */
|
||||
.bnd-add-com { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.bnd-add-com textarea {
|
||||
@@ -702,7 +705,50 @@ function _renderDetalle(d) {
|
||||
<button onclick="_bndEnviarCom(${t.id})"><i class="fas fa-paper-plane"></i> Enviar</button>
|
||||
</div></div>`;
|
||||
|
||||
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom;
|
||||
const secTraza = `<div class="bnd-sec">
|
||||
<div class="bnd-sec-title"><i class="fas fa-clock-rotate-left"></i> Qué pasó con este turno</div>
|
||||
<div id="bnd-traza"><span style="color:#94a3b8;font-size:.82rem">
|
||||
<i class="fas fa-spinner fa-spin"></i> Cargando…</span></div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom + secTraza;
|
||||
_bndCargarTraza(t.id);
|
||||
}
|
||||
|
||||
// ── Bitácora del turno ────────────────────────────────────────
|
||||
// Los comentarios ya salen arriba; aquí va el recorrido completo, incluidas las
|
||||
// acciones administrativas (por ejemplo el retiro de un formulario, con motivo).
|
||||
const BND_TRAZA_ICO = {
|
||||
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
|
||||
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
|
||||
doc_ok:'fa-file-signature', comentario:'fa-comment',
|
||||
alerta:'fa-triangle-exclamation', admin:'fa-user-shield',
|
||||
};
|
||||
const BND_TRAZA_COLOR = { alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb', fin:'#0f766e', admin:'#7c3aed' };
|
||||
|
||||
async function _bndCargarTraza(turnoId) {
|
||||
const cont = document.getElementById('bnd-traza');
|
||||
if (!cont) return;
|
||||
const esc = x => String(x ?? '').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||
try {
|
||||
const j = await fetch(`modules/turnero/api/get_traza_turno.php?turno_id=${turnoId}`).then(r=>r.json());
|
||||
if (!j.ok || !j.eventos?.length) {
|
||||
cont.innerHTML = '<span style="color:#94a3b8;font-size:.82rem">Sin registros</span>'; return;
|
||||
}
|
||||
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
|
||||
cont.innerHTML = '<div class="bnd-traza">' + j.eventos.map(e => `
|
||||
<div class="bnd-traza-fila">
|
||||
<span class="bnd-traza-hora">${hora(e.cuando)}</span>
|
||||
<i class="fas ${BND_TRAZA_ICO[e.tipo]||'fa-circle'}"
|
||||
style="color:${BND_TRAZA_COLOR[e.tipo]||'#94a3b8'};width:14px;font-size:.72rem;margin-top:2px"></i>
|
||||
<div style="flex:1">${esc(e.texto)}
|
||||
${e.quien ? `<span style="color:#64748b;font-style:italic"> ${esc(e.quien)}</span>` : ''}
|
||||
${e.extra ? `<div style="color:#b45309;font-size:.74rem">${esc(e.extra)}</div>` : ''}
|
||||
</div>
|
||||
</div>`).join('') + '</div>';
|
||||
} catch (_) {
|
||||
cont.innerHTML = '<span style="color:#94a3b8;font-size:.82rem">Error al cargar</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Marcar visto ──────────────────────────────────────────────
|
||||
|
||||
@@ -578,6 +578,10 @@ let state = {
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadContacts();
|
||||
// Permite llegar directo a una conversación desde otra pantalla, por ejemplo
|
||||
// desde la bitácora del turno en el historial: ?v=chat&user_id=123
|
||||
const _uid = parseInt(new URLSearchParams(location.search).get('user_id') || '', 10);
|
||||
if (_uid > 0) openChat(_uid);
|
||||
document.getElementById('searchInput').addEventListener('input', debounce(() => {
|
||||
loadContacts(document.getElementById('searchInput').value.trim());
|
||||
}, 300));
|
||||
|
||||
@@ -12,6 +12,9 @@ require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
require_once __DIR__ . '/../_acceso.php';
|
||||
turneroExigirRol(['supervisor']);
|
||||
|
||||
$db = Database::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
@@ -1157,10 +1160,13 @@ $tab = $_GET['tab'] ?? 'lugares';
|
||||
<div class="tv-media-item d-flex align-items-center gap-3 p-2" data-id="<?= (int)$m['id'] ?>"
|
||||
style="border:1px solid #e2e8f0;border-radius:10px">
|
||||
<i class="fas fa-grip-vertical text-muted" style="cursor:grab"></i>
|
||||
<?php /* Miniatura cuadrada, igual que la pantalla del televisor: con la
|
||||
antigua de 90x60 el material cuadrado se veía recortado aquí y
|
||||
no coincidía con lo que después salía al aire. */ ?>
|
||||
<?php if ($m['tipo'] === 'video'): ?>
|
||||
<video muted style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>"></video>
|
||||
<video muted style="width:64px;height:64px;object-fit:cover;border-radius:6px;background:#000" src="<?= htmlspecialchars($m['url']) ?>"></video>
|
||||
<?php else: ?>
|
||||
<img style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>" alt="">
|
||||
<img style="width:64px;height:64px;object-fit:cover;border-radius:6px;background:#000" src="<?= htmlspecialchars($m['url']) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
<div class="flex-grow-1">
|
||||
<div class="small fw-semibold"><i class="fas fa-<?= $m['tipo']==='video'?'film':'image' ?> me-1"></i><?= $m['tipo']==='video'?'Video':'Imagen' ?></div>
|
||||
|
||||
@@ -47,6 +47,8 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
|
||||
.kpi-card .kpi-val { font-size:2rem; font-weight:800; line-height:1.1; }
|
||||
.kpi-card .kpi-lbl { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#64748b; margin-top:4px; }
|
||||
.kpi-card .kpi-sub { font-size:.75rem; color:#94a3b8; margin-top:2px; }
|
||||
.kpi-ayuda { font-size:.62rem; color:#94a3b8; line-height:1.3; margin-top:5px;
|
||||
border-top:1px solid #f1f5f9; padding-top:5px; }
|
||||
|
||||
/* ── Charts / tables section ── */
|
||||
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; }
|
||||
@@ -83,6 +85,13 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
|
||||
.det-row td { padding:0 !important; border-top:none !important; }
|
||||
.det-inner { padding:12px 16px 14px; border-top:2px solid color-mix(in srgb, var(--brand,#1565c0) 20%, #e2e8f0); background:#f8fafc; }
|
||||
|
||||
/* ── Origen de cada nota ── */
|
||||
.com-tipo { font-size:.62rem; font-weight:700; letter-spacing:.04em; padding:1px 7px;
|
||||
border-radius:10px; text-transform:uppercase; margin-right:6px; }
|
||||
.com-tipo.recepcion { background:#dbeafe; color:#1d4ed8; }
|
||||
.com-tipo.muestras { background:#dcfce7; color:#15803d; }
|
||||
.com-tipo.general { background:#fef3c7; color:#92400e; }
|
||||
|
||||
/* ── Sesión badge ── */
|
||||
.sesion-open { background:#dcfce7; color:#15803d; }
|
||||
.sesion-closed { background:#f1f5f9; color:#64748b; }
|
||||
@@ -280,6 +289,18 @@ try {
|
||||
<div class="lugar-grid" id="lugarGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Desglose por recepcionista -->
|
||||
<div class="section-card" id="sectionRecepcionista" style="display:none">
|
||||
<div class="section-title"><i class="fas fa-user-check me-1"></i>Por recepcionista</div>
|
||||
<div class="lugar-grid" id="recepcionistaGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Desglose por bacteriólogo -->
|
||||
<div class="section-card" id="sectionBacteriologo" style="display:none">
|
||||
<div class="section-title"><i class="fas fa-user-nurse me-1"></i>Por bacteriólogo</div>
|
||||
<div class="lugar-grid" id="bacteriologoGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla detalle de turnos -->
|
||||
<div class="section-card">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||
@@ -438,7 +459,8 @@ function mostrarError(msg) {
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
function renderDashboard(json) {
|
||||
const { sesion, resumen, por_prioridad, por_lugar, consent_stats = {}, turnos } = json;
|
||||
const { sesion, resumen, por_prioridad, por_lugar, consent_stats = {}, turnos,
|
||||
por_recepcionista = [], por_bacteriologo = [] } = json;
|
||||
|
||||
// ── Sesión info ────────────────────────────────────────
|
||||
const si = document.getElementById('sesionInfo');
|
||||
@@ -464,14 +486,22 @@ function renderDashboard(json) {
|
||||
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
|
||||
|
||||
document.getElementById('kpiGrid').innerHTML = `
|
||||
${kpiCard(total, '📋 Total turnos', '')}
|
||||
${kpiCard(atendidos,'✅ Atendidos', pct + '%')}
|
||||
${kpiCard(enEspera, '⏳ En espera', '')}
|
||||
${kpiCard(ausentes, '🚫 Ausentes', '')}
|
||||
${kpiCard(cancelados,'❌ Cancelados', '')}
|
||||
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')}
|
||||
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')}
|
||||
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total prom.', 'puerta a puerta')}
|
||||
${kpiCard(total, '📋 Turnos del día', '',
|
||||
'Todos los turnos generados hoy, sin importar cómo terminaron.')}
|
||||
${kpiCard(atendidos, '✅ Atendidos', pct + ' % del día',
|
||||
'Ya terminaron su atención, más los que están siendo atendidos en este momento.')}
|
||||
${kpiCard(enEspera, '⏳ Esperando', '',
|
||||
'Todavía no los llaman, o ya salieron de recepción y esperan el puesto de toma.')}
|
||||
${kpiCard(ausentes, '🚫 No se presentaron', '',
|
||||
'Se les llamó y no llegaron. Recepción registra el motivo al marcarlos.')}
|
||||
${kpiCard(cancelados, '❌ Cancelados', '',
|
||||
'Turnos anulados antes de atenderlos.')}
|
||||
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera para recepción', 'sacar el turno → lo llaman',
|
||||
'Cuánto aguarda el paciente sentado desde que saca el turno hasta que recepción lo atiende.')}
|
||||
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Tiempo en el puesto', 'toma de muestras',
|
||||
'Lo que dura la toma. En protocolos prolongados (curvas) se cuenta hasta la primera toma: las horas de espera del examen no son trabajo del puesto.')}
|
||||
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total en el laboratorio', 'llegada → salida',
|
||||
'Todo el recorrido: espera + recepción + espera del puesto + toma. Por eso es mayor que la suma de las dos casillas anteriores.')}
|
||||
`;
|
||||
|
||||
// ── Consentimientos ────────────────────────────────────
|
||||
@@ -529,16 +559,51 @@ function renderDashboard(json) {
|
||||
secLug.style.display = 'none';
|
||||
}
|
||||
|
||||
// ── Tarjetas por persona ───────────────────────────────
|
||||
renderPersonas('sectionRecepcionista', 'recepcionistaGrid', por_recepcionista, [
|
||||
['Turnos', p => p.total],
|
||||
['Atendidos', p => p.atendidos],
|
||||
['Ausentes', p => p.ausentes],
|
||||
['T. recepción prom.', p => p.tiempo_promedio_min !== null ? p.tiempo_promedio_min + ' min' : '—'],
|
||||
]);
|
||||
|
||||
renderPersonas('sectionBacteriologo', 'bacteriologoGrid', por_bacteriologo, [
|
||||
['Turnos', p => p.total],
|
||||
['Finalizados', p => p.finalizados],
|
||||
['En servicio', p => p.en_servicio],
|
||||
['T. servicio prom.', p => p.tiempo_promedio_min !== null ? p.tiempo_promedio_min + ' min' : '—'],
|
||||
]);
|
||||
|
||||
// ── Tabla de turnos ────────────────────────────────────
|
||||
_turnos = turnos;
|
||||
renderTabla(turnos);
|
||||
}
|
||||
|
||||
function kpiCard(val, lbl, sub) {
|
||||
return `<div class="kpi-card">
|
||||
// Tarjetas "quién atendió a cuántos". Reutiliza el grid de lugares para que
|
||||
// las tres secciones se vean iguales.
|
||||
function renderPersonas(sectionId, gridId, filas, columnas) {
|
||||
const sec = document.getElementById(sectionId);
|
||||
if (!filas || !filas.length) { sec.style.display = 'none'; return; }
|
||||
sec.style.display = '';
|
||||
document.getElementById(gridId).innerHTML = filas.map(p => `
|
||||
<div class="lugar-card">
|
||||
<div class="lc-name"><i class="fas fa-user text-primary me-1"></i>${escHtml(p.nombre || '—')}</div>
|
||||
${columnas.map(([lbl, val]) =>
|
||||
`<div class="lc-row"><span>${lbl}</span><span>${escHtml(String(val(p) ?? '—'))}</span></div>`
|
||||
).join('')}
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function kpiCard(val, lbl, sub, ayuda) {
|
||||
// `ayuda` explica de dónde sale el número. Las etiquetas cortas se prestan
|
||||
// para malentendidos —«servicio» no es lo mismo que «tiempo en el
|
||||
// laboratorio»— y quien lee el tablero no tiene por qué adivinarlo.
|
||||
const t = ayuda ? ` title="${escHtml(ayuda)}"` : '';
|
||||
return `<div class="kpi-card"${t}>
|
||||
<div class="kpi-val">${val !== null && val !== undefined ? escHtml(String(val)) : '—'}</div>
|
||||
<div class="kpi-lbl">${lbl}</div>
|
||||
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
|
||||
${ayuda ? `<div class="kpi-ayuda">${escHtml(ayuda)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -657,8 +722,9 @@ function renderDashDetalle(t) {
|
||||
// Comentarios
|
||||
if (t.comentarios && t.comentarios.length) {
|
||||
grid += `<div style="border-top:1px solid #e2e8f0;padding-top:8px">
|
||||
<div style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;margin-bottom:5px"><i class="fas fa-comments me-1"></i>Comentarios</div>
|
||||
<div style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;margin-bottom:5px"><i class="fas fa-comments me-1"></i>Notas y observaciones (${t.comentarios.length})</div>
|
||||
${t.comentarios.map(c=>`<div style="font-size:.8rem;padding:4px 0;border-bottom:1px solid #f1f5f9">
|
||||
<span class="com-tipo ${e(c.tipo||'general')}">${({recepcion:'Recepción',muestras:'Toma de muestras',general:'General'})[c.tipo]||c.tipo}</span>
|
||||
<strong>${e(c.usuario_nombre)}</strong>
|
||||
<span style="color:#94a3b8;font-size:.72rem;margin-left:6px">${fmt(c.creado_at)}</span>
|
||||
<div style="color:#475569">${e(c.comentario)}</div>
|
||||
|
||||
@@ -271,9 +271,13 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
|
||||
</div>
|
||||
|
||||
<!-- Panel publicitario (se activa con ?video=URL) -->
|
||||
<div id="video-panel" style="display:none;overflow:hidden;background:#000;position:relative">
|
||||
<!-- El material es cuadrado: la caja también, y centrada. Estirada a
|
||||
toda la altura de una columna angosta, cover recortaba los bordes
|
||||
del video y solo se veía la franja central. -->
|
||||
<div id="video-panel" style="display:none;overflow:hidden;background:#000;position:relative;
|
||||
align-items:center;justify-content:center">
|
||||
<video id="video-pub" autoplay muted loop playsinline
|
||||
style="width:100%;height:100%;object-fit:cover">
|
||||
style="width:100%;height:auto;aspect-ratio:1/1;max-height:100%;object-fit:cover">
|
||||
<source id="video-src" src="">
|
||||
</video>
|
||||
</div>
|
||||
@@ -338,7 +342,7 @@ document.getElementById('lbl-area').textContent =
|
||||
const body = document.getElementById('display-body');
|
||||
src.src = videoUrl;
|
||||
vid.load();
|
||||
panel.style.display = 'block';
|
||||
panel.style.display = 'flex'; // flex, para poder centrar el cuadro
|
||||
// 3 columnas: turno | video | cola
|
||||
body.style.gridTemplateColumns = '1fr 320px 280px';
|
||||
})();
|
||||
|
||||
@@ -112,8 +112,12 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
/* La columna del video se dimensiona sola: `auto` la deja del ancho que
|
||||
pida su contenido, y el contenido es un cuadrado tan alto como el
|
||||
cuerpo. Antes era una franja fija del 29% —proporción de reel—, así
|
||||
que un video cuadrado quedaba diminuto y con medio espacio vacío. */
|
||||
.pg-body.has-video {
|
||||
grid-template-columns: 1fr 29%;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.pg-body.has-video .turno-activo { display: none; }
|
||||
|
||||
@@ -240,16 +244,29 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
}
|
||||
.ul-item.activo .ul-dest { color: rgba(255,255,255,.85); }
|
||||
|
||||
/* ── Video reel ── */
|
||||
/* ── Video cuadrado ──
|
||||
El material ya no es de proporción reel sino cuadrado. La caja toma
|
||||
todo el alto disponible y el ancho lo deduce de ahí (aspect-ratio),
|
||||
así que el cuadrado sale tan grande como quepa. Se limita a la mitad
|
||||
de la pantalla para no ahogar la columna de los llamados. */
|
||||
.reel-wrap {
|
||||
display: none;
|
||||
height: 100%;
|
||||
max-width: 50vw;
|
||||
overflow: hidden;
|
||||
padding: 8px 8px 8px 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.reel-wrap.has-video { display: block; }
|
||||
.reel-wrap.has-video { display: flex; }
|
||||
/* El cuadrado se define aquí y no en el contenedor: con box-sizing
|
||||
border-box, el padding asimétrico del contenedor deformaría la
|
||||
proporción. La columna se ajusta sola al ancho que resulte. */
|
||||
.reel-wrap .reel-inner {
|
||||
width: 100%; height: 100%;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-width: 100%;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: -8px 0 30px rgba(0,0,0,.08);
|
||||
@@ -378,7 +395,7 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
|
||||
<div id="sonido-banner" onclick="activarSonido()" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem">
|
||||
<div id="sonido-banner" onclick="activarSonido(true)" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem">
|
||||
<i class="fas fa-volume-mute"></i> Haz clic aquí para activar el sonido
|
||||
</div>
|
||||
|
||||
@@ -404,7 +421,7 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
<div class="hd-right">
|
||||
<div class="live-dot"></div>
|
||||
<div class="reloj" id="reloj">--:--:--</div>
|
||||
<button class="btn-fs" id="btn-sonido" onclick="activarSonido()" title="Activar sonido">
|
||||
<button class="btn-fs" id="btn-sonido" onclick="activarSonido(true)" title="Activar sonido">
|
||||
<i class="fas fa-volume-mute"></i>
|
||||
</button>
|
||||
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
|
||||
@@ -499,14 +516,22 @@ function tick() {
|
||||
tick(); setInterval(tick, 1000);
|
||||
|
||||
/* ── Audio ── */
|
||||
let audioCtx = null, sonidoActivo = false;
|
||||
// Arranca encendido. Esta pantalla vive en un televisor que nadie toca: si el
|
||||
// sonido esperara un clic, cualquier recarga —corte de red, reinicio, refresco—
|
||||
// la dejaba muda hasta que alguien fuera físicamente a tocarla, que es lo que
|
||||
// venía pasando. Si el navegador termina bloqueándolo, se avisa con el letrero.
|
||||
let audioCtx = null, sonidoActivo = true;
|
||||
|
||||
function activarSonido() {
|
||||
function activarSonido(porGesto) {
|
||||
try {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
} else if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume();
|
||||
// resume() es asíncrono: sin esto el letrero parpadeaba al abrir,
|
||||
// porque el estado todavía decía "suspended" cuando ya iba a sonar.
|
||||
audioCtx.onstatechange = revisarBloqueo;
|
||||
}
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume().catch(() => {});
|
||||
}
|
||||
const g = audioCtx.createGain(); g.gain.setValueAtTime(0.001, audioCtx.currentTime);
|
||||
const o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
|
||||
@@ -516,13 +541,39 @@ function activarSonido() {
|
||||
localStorage.setItem('turneroSonido', '1');
|
||||
const btn = document.getElementById('btn-sonido');
|
||||
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
|
||||
const banner = document.getElementById('sonido-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
setTimeout(playBeep, 100);
|
||||
// El pito de confirmación solo cuando alguien tocó: al arrancar sola, la
|
||||
// pantalla no tiene por qué pitar cada vez que se recarga.
|
||||
if (porGesto) setTimeout(playBeep, 100);
|
||||
revisarBloqueo();
|
||||
}
|
||||
// Sin once:true para que cualquier clic reactive si la página recargó
|
||||
document.addEventListener('click', () => { if (!sonidoActivo) activarSonido(); });
|
||||
document.addEventListener('touchstart', () => { if (!sonidoActivo) activarSonido(); });
|
||||
|
||||
/**
|
||||
* El navegador puede negarse a sonar sin un gesto humano. No hay forma de
|
||||
* saberlo preguntando, solo mirando si el contexto quedó suspendido: si es así
|
||||
* se muestra el letrero para que alguien toque la pantalla una vez.
|
||||
*
|
||||
* Se evita del todo abriendo Chrome en el televisor con
|
||||
* --autoplay-policy=no-user-gesture-required
|
||||
*/
|
||||
function revisarBloqueo() {
|
||||
const banner = document.getElementById('sonido-banner');
|
||||
if (!banner) return;
|
||||
const bloqueado = !audioCtx || audioCtx.state === 'suspended';
|
||||
banner.style.display = bloqueado ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// Intento de arranque automático, en cuanto la página está lista
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => activarSonido(false));
|
||||
} else {
|
||||
activarSonido(false);
|
||||
}
|
||||
// Reintento: Chrome a veces deja el contexto suspendido un instante al abrir
|
||||
setTimeout(() => { if (audioCtx && audioCtx.state === 'suspended') activarSonido(false); }, 2000);
|
||||
|
||||
// Cualquier gesto sirve de respaldo si el navegador bloqueó el arranque
|
||||
document.addEventListener('click', () => activarSonido(true));
|
||||
document.addEventListener('touchstart', () => activarSonido(true));
|
||||
|
||||
function playBeep() {
|
||||
if (!sonidoActivo || !audioCtx) return;
|
||||
@@ -544,47 +595,247 @@ function playBeep() {
|
||||
|
||||
// Selección de voz española — cargada una vez, reutilizada en cada anuncio
|
||||
let _vozES = null;
|
||||
// Voces preferidas, por nombre. Sabina (Windows) y Paulina (macOS) son las
|
||||
// mexicanas locales: salen del propio equipo, así que nunca se cortan. Una
|
||||
// colombiana local no existe —la es-CO de Chrome es de Google y se baja de
|
||||
// internet en cada llamado—, y para leer letras, números y un nombre propio
|
||||
// el acento mexicano se oye natural aquí; el de España no.
|
||||
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
|
||||
|
||||
function getVozES() {
|
||||
if (_vozES) return _vozES;
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
// Preferencia: es-CO → es-419 → es-MX → es-US → es-ES → cualquier es-*
|
||||
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
|
||||
const v = voices.find(v => v.lang === lang);
|
||||
if (v) { _vozES = v; return v; }
|
||||
const voces = window.speechSynthesis.getVoices().filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
|
||||
if (!voces.length) return null;
|
||||
|
||||
const locales = voces.filter(v => v.localService);
|
||||
const elegir = v => { _vozES = v; return v; };
|
||||
|
||||
// 1. Las pedidas por nombre, si están instaladas
|
||||
for (const nombre of VOCES_PREFERIDAS) {
|
||||
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
|
||||
if (v) return elegir(v);
|
||||
}
|
||||
const v = voices.find(v => v.lang.startsWith('es'));
|
||||
if (v) { _vozES = v; }
|
||||
return _vozES;
|
||||
// 2. Cualquier otra local latinoamericana
|
||||
for (const lang of ['es-MX', 'es-419', 'es-US', 'es-CO']) {
|
||||
const v = locales.find(v => v.lang === lang);
|
||||
if (v) return elegir(v);
|
||||
}
|
||||
// 3. Cualquier local, aunque sea de España: peor acento, pero no se corta
|
||||
if (locales.length) return elegir(locales[0]);
|
||||
|
||||
// 4. Sin ninguna local, queda la remota. Suena mejor, pero depende del wifi:
|
||||
// es la que venía entrecortándose.
|
||||
for (const lang of ['es-CO', 'es-419', 'es-MX', 'es-US', 'es-ES']) {
|
||||
const v = voces.find(v => v.lang === lang);
|
||||
if (v) return elegir(v);
|
||||
}
|
||||
return elegir(voces[0]);
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
|
||||
// Keepalive: Chrome pausa speechSynthesis tras ~15 min — correr siempre, no solo cuando !speaking
|
||||
// Keepalive con TRABAJO MUDO. El motor de voz (comprobado en macOS, y
|
||||
// reportado también en Windows) se duerme tras un rato de ocio, y el primer
|
||||
// speak() de ahí en adelante se ATASCA: speaking=true, cero audio, ni
|
||||
// 'start' ni 'error' jamás. pause()/resume() no lo mantiene despierto —
|
||||
// se verificó en vivo el 23/08: el primer llamado tras el ocio salía solo
|
||||
// con el pito. Lo único que lo mantiene despierto es hablar de verdad:
|
||||
// una letra a volumen cero cada 25 s, inaudible, con el motor en reposo.
|
||||
setInterval(() => {
|
||||
window.speechSynthesis.pause();
|
||||
if (!sonidoActivo) return;
|
||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
|
||||
const u = new SpeechSynthesisUtterance('a');
|
||||
u.volume = 0; u.rate = 2;
|
||||
window.speechSynthesis.resume();
|
||||
}, 10000);
|
||||
window.speechSynthesis.speak(u);
|
||||
}, 25000);
|
||||
}
|
||||
// Auto-restore sonido si estaba activo antes de un reload
|
||||
if (localStorage.getItem('turneroSonido') === '1') {
|
||||
const banner = document.getElementById('sonido-banner');
|
||||
if (banner) banner.style.display = 'flex';
|
||||
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
|
||||
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
|
||||
|
||||
// Espera antes de hablar: el pito dura 0,65 s y hablando encima tapaba
|
||||
// "Turno X", que desde lejos sonaba como si el anuncio empezara ya empezado.
|
||||
// A nivel de archivo porque showAnnouncement() también lo necesita.
|
||||
const BEEP_MS = 700;
|
||||
|
||||
// ── Anclaje contra el recolector de basura ──
|
||||
// Defecto conocido de Chrome: si nada referencia la SpeechSynthesisUtterance,
|
||||
// el recolector puede llevársela A MITAD DE FRASE. El audio se corta en seco y
|
||||
// 'end' no llega nunca. Como depende de cuándo pase el recolector, ocurre "a
|
||||
// veces" y nunca se reproduce a voluntad. Cada locución queda anclada aquí
|
||||
// hasta que termina.
|
||||
const _uttAncladas = new Set();
|
||||
|
||||
// Token de generación: cancel() dispara 'error' (interrupted) sobre la
|
||||
// locución vieja, y sin este guardián ese handler volvía a lanzar el
|
||||
// reintento: DOS voces superpuestas diciendo lo mismo, a veces. Cada decir()
|
||||
// toma un número; si al disparársele un evento ya no es el vigente, la
|
||||
// locución fue superada (por un reintento o por el anuncio siguiente) y sus
|
||||
// handlers no deben hacer nada más que soltar el anclaje.
|
||||
let _vozGen = 0;
|
||||
|
||||
// Vigía de congelamiento: Chrome frena los relojes de una pestaña oculta a ~1
|
||||
// tic por minuto. En ese estado la pantalla NO VE los llamados intermedios y
|
||||
// no anuncia — y desde afuera parece un fallo de la voz. Si entre dos tics
|
||||
// pasan más de 10 s, se deja constancia con el tamaño del hueco.
|
||||
let _ultimoTic = Date.now();
|
||||
setInterval(() => {
|
||||
const ahora = Date.now();
|
||||
const hueco = ahora - _ultimoTic;
|
||||
_ultimoTic = ahora;
|
||||
if (hueco > 10000) vozLog('lag', { gap_s: Math.round(hueco / 1000) });
|
||||
}, 2000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) vozLog('lag', { oculta: 1 });
|
||||
});
|
||||
|
||||
// ── Bitácora de la voz ──
|
||||
// Los fallos intermitentes no se pueden depurar mirando la pantalla: hay que
|
||||
// registrarlos cuando ocurren. Cada evento va a un anillo local (últimos 200,
|
||||
// consultable con localStorage.tvVozLog en la consola del televisor) y al
|
||||
// servidor con sendBeacon, que no bloquea ni exige respuesta.
|
||||
function vozLog(evento, datos) {
|
||||
const fila = Object.assign({ t: new Date().toISOString().slice(0, 19), e: evento }, datos || {});
|
||||
try {
|
||||
const ring = JSON.parse(localStorage.getItem('tvVozLog') || '[]');
|
||||
ring.push(fila);
|
||||
while (ring.length > 200) ring.shift();
|
||||
localStorage.setItem('tvVozLog', JSON.stringify(ring));
|
||||
} catch (_) {}
|
||||
try {
|
||||
navigator.sendBeacon(
|
||||
'<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/log_tv.php',
|
||||
new Blob([JSON.stringify(fila)], { type: 'application/json' })
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function anunciarTurno(codigo, destino, paciente) {
|
||||
/**
|
||||
* Dice el llamado en voz alta.
|
||||
*
|
||||
* Avisa por `onFin` cuando terminó de hablar, o cuando quedó claro que no va a
|
||||
* hablar. Lo segundo importa tanto como lo primero: el cartel se cierra con esa
|
||||
* señal, y si nunca llegaba se quedaba pegado en pantalla.
|
||||
*
|
||||
* Si la voz elegida no arranca —pasa cuando el equipo la lista pero no la puede
|
||||
* usar— se reintenta una vez con la del navegador, en vez de quedarse mudo.
|
||||
*
|
||||
* @return bool si se intentó hablar. Falso significa pantalla sin sonido.
|
||||
*/
|
||||
function anunciarTurno(codigo, destino, paciente, onFin) {
|
||||
playBeep();
|
||||
if (!sonidoActivo || !('speechSynthesis' in window)) return null;
|
||||
if (!sonidoActivo || !('speechSynthesis' in window)) return false;
|
||||
|
||||
const letras = codigo.split('').join(' ');
|
||||
const nomVoz = paciente ? paciente.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) : null;
|
||||
// \w no cuenta las letras acentuadas, así que trataba la tilde como
|
||||
// separador: "maría" salía "MaríA" y la voz lo pronunciaba raro.
|
||||
const nomVoz = paciente
|
||||
? paciente.toLowerCase().replace(/(^|\s)(\p{L})/gu, (_, sep, c) => sep + c.toUpperCase())
|
||||
: null;
|
||||
let texto = `Turno ${letras}`;
|
||||
if (nomVoz) texto += `, ${nomVoz}`;
|
||||
texto += `, pase a ${destino}`;
|
||||
const utt = new SpeechSynthesisUtterance(texto);
|
||||
utt.lang = 'es-CO';
|
||||
const voz = getVozES();
|
||||
if (voz) utt.voice = voz;
|
||||
utt.rate = 0.85; utt.pitch = 1.05; utt.volume = 1;
|
||||
setTimeout(() => { window.speechSynthesis.cancel(); window.speechSynthesis.speak(utt); }, 300);
|
||||
return utt;
|
||||
|
||||
let avisado = false;
|
||||
const avisar = () => { if (!avisado) { avisado = true; onFin(); } };
|
||||
|
||||
const decir = (esReintento) => {
|
||||
const utt = new SpeechSynthesisUtterance(texto);
|
||||
utt.lang = 'es-CO';
|
||||
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
|
||||
|
||||
_uttAncladas.add(utt);
|
||||
const soltar = () => _uttAncladas.delete(utt);
|
||||
const pedido = Date.now();
|
||||
const gen = ++_vozGen;
|
||||
const vigente = () => gen === _vozGen;
|
||||
|
||||
let arranco = false;
|
||||
utt.addEventListener('start', () => {
|
||||
arranco = true;
|
||||
if (!vigente()) return;
|
||||
vozLog('start', { cod: codigo, ms: Date.now() - pedido,
|
||||
voz: utt.voice ? utt.voice.name : '(navegador)',
|
||||
local: utt.voice ? !!utt.voice.localService : null,
|
||||
re: esReintento ? 1 : 0 });
|
||||
});
|
||||
utt.addEventListener('end', () => {
|
||||
soltar();
|
||||
if (!vigente()) return; // la canceló un reintento o el anuncio siguiente
|
||||
vozLog('end', { cod: codigo, dur: Date.now() - pedido });
|
||||
avisar();
|
||||
});
|
||||
utt.addEventListener('error', (ev) => {
|
||||
soltar();
|
||||
if (!vigente()) return; // 'interrupted' por un cancel nuestro: no reintentar
|
||||
vozLog('error', { cod: codigo, err: ev.error || '?', re: esReintento ? 1 : 0 });
|
||||
if (esReintento) { avisar(); return; }
|
||||
// Invalidar ANTES de cancelar: el cancel dispara eventos sobre esta
|
||||
// misma locución y sin esto el reintento salía por partida doble.
|
||||
_vozGen++;
|
||||
window.speechSynthesis.cancel();
|
||||
setTimeout(() => decir(true), 500);
|
||||
});
|
||||
|
||||
// Que speak() no lance nada no significa que vaya a sonar: si el
|
||||
// navegador bloquea el audio, o la voz no sirve, no pasa absolutamente
|
||||
// nada y 'error' tampoco llega. Solo se nota porque 'start' no ocurre.
|
||||
// Rendirse con esta locución: romper el atasco y pasar al reintento
|
||||
// (o, si ya era el reintento, declarar mudo el llamado y soltar el cartel).
|
||||
const rendirse = () => {
|
||||
soltar();
|
||||
if (esReintento) {
|
||||
vozLog('mudo', { cod: codigo });
|
||||
avisar(); return;
|
||||
}
|
||||
// atasco=1: el motor JURA estar hablando (speaking) pero 'start'
|
||||
// nunca llegó — el modo de fallo verificado en vivo el 23/08.
|
||||
vozLog('sin_start', { cod: codigo,
|
||||
atasco: window.speechSynthesis.speaking ? 1 : 0 });
|
||||
_vozGen++; // los eventos del cancel no valen
|
||||
window.speechSynthesis.cancel();
|
||||
setTimeout(() => decir(true), 500); // respiro: rehablar YA se atasca
|
||||
};
|
||||
const margen = (esReintento ? 0 : BEEP_MS) + 2500;
|
||||
setTimeout(() => {
|
||||
if (!vigente()) { soltar(); return; }
|
||||
if (arranco) return;
|
||||
// pending sin speaking = voz remota descargando: una prórroga de 6 s
|
||||
// y solo entonces se da por perdida. Cortarla de una fue el defecto
|
||||
// del entrecortado original.
|
||||
if (window.speechSynthesis.pending && !window.speechSynthesis.speaking) {
|
||||
setTimeout(() => { if (!vigente() || arranco) return; rendirse(); }, 6000);
|
||||
return;
|
||||
}
|
||||
// speaking sin 'start' = ATASCO (finge hablar sin audio). Una locución
|
||||
// que de verdad suena dispara 'start' de inmediato: romperlo.
|
||||
rendirse();
|
||||
}, margen);
|
||||
|
||||
// Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un
|
||||
// speak() encadenado a un cancel() en el mismo ciclo.
|
||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
setTimeout(() => {
|
||||
// La voz se elige aquí y no al crear el anuncio: el navegador carga
|
||||
// la lista de forma asíncrona, y en los primeros llamados tras abrir
|
||||
// la página todavía venía vacía. Entonces no se asignaba voz y
|
||||
// hablaba la del navegador —de ahí que a veces sonara un hombre y a
|
||||
// veces una mujer—. A esta altura ya está cargada.
|
||||
if (!esReintento) {
|
||||
const voz = getVozES();
|
||||
if (voz) utt.voice = voz;
|
||||
}
|
||||
// Tras un cancel(), en Windows y ChromeOS el motor puede quedar en
|
||||
// pausa: speak() encola y jamás suena, sin error alguno. resume()
|
||||
// sobre un motor sano no hace nada, así que se llama siempre.
|
||||
window.speechSynthesis.resume();
|
||||
window.speechSynthesis.speak(utt);
|
||||
}, esReintento ? 250 : BEEP_MS);
|
||||
};
|
||||
|
||||
decir(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Fullscreen ── */
|
||||
@@ -629,8 +880,6 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
|
||||
|
||||
ov.classList.add('visible');
|
||||
|
||||
const utt = anunciarTurno(codigo, destino, paciente);
|
||||
|
||||
bar.style.transition = 'none';
|
||||
bar.style.width = '100%';
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
@@ -645,9 +894,28 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
|
||||
ov.classList.remove('visible');
|
||||
setTimeout(processQueue, 370);
|
||||
}
|
||||
// Cierra al terminar el TTS; mínimo 5s visual
|
||||
if (utt) utt.addEventListener('end', () => setTimeout(dismiss, 500));
|
||||
setTimeout(dismiss, 5000);
|
||||
|
||||
// El cartel se cierra cuando terminó de hablar Y se cumplió el mínimo
|
||||
// visual. Cerrar a los 5 s sin esperar la voz hacía que el siguiente
|
||||
// llamado cancelara al anterior a media frase.
|
||||
const MIN_VISIBLE = 5000;
|
||||
const abiertoEn = Date.now();
|
||||
const cerrarCuandoToque = () => {
|
||||
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
|
||||
};
|
||||
|
||||
// anunciarTurno avisa tanto si terminó de hablar como si quedó claro que no
|
||||
// va a hablar. Esperar solo el fin de la voz dejaba el cartel pegado en las
|
||||
// pantallas donde el navegador bloquea el audio.
|
||||
const intentoHablar = anunciarTurno(codigo, destino, paciente,
|
||||
() => setTimeout(cerrarCuandoToque, 400));
|
||||
|
||||
if (intentoHablar) {
|
||||
// Red de seguridad: Chrome a veces no emite 'end' a mitad de una frase
|
||||
setTimeout(dismiss, 20000);
|
||||
} else {
|
||||
setTimeout(dismiss, MIN_VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── State ── */
|
||||
@@ -699,18 +967,16 @@ function renderSnapshot(snap) {
|
||||
});
|
||||
lastSeenKeys = currentKeys;
|
||||
|
||||
if (_firstLoad) {
|
||||
_firstLoad = false;
|
||||
const sorted = allTurnos.slice().sort((a, b) => new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0));
|
||||
if (sorted.length > 0) {
|
||||
const t = sorted[0];
|
||||
const c = t.prioridad_color || '<?= $_labColor ?>';
|
||||
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
|
||||
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
|
||||
announcedCalls.add(key);
|
||||
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
|
||||
}
|
||||
}
|
||||
// Al abrir la pantalla NO se anuncia nada por voz. Antes se llamaba al turno
|
||||
// que estuviera arriba, pero eso no es un llamado nuevo: es el estado en que
|
||||
// se encontró el puesto. Si allí había un turno sin cerrar de horas atrás, el
|
||||
// televisor gritaba el nombre de un paciente que ya se había ido. Y como esta
|
||||
// pantalla se recarga sola, cada recarga era una oportunidad de equivocarse.
|
||||
//
|
||||
// El recorrido de arriba ya dio por vistos los turnos presentes —registra la
|
||||
// clave sin anunciar mientras _firstLoad siga en pie—, así que aquí solo hay
|
||||
// que levantar la bandera: de la siguiente vuelta en adelante sí se anuncia.
|
||||
if (_firstLoad) _firstLoad = false;
|
||||
|
||||
if (anuncioNuevo) renderLlamados();
|
||||
|
||||
@@ -797,7 +1063,25 @@ async function cargarSnapshot() {
|
||||
}
|
||||
|
||||
cargarSnapshot();
|
||||
setInterval(cargarSnapshot, 2000);
|
||||
// 1 s en vez de 2: el llamado salía con hasta 2 s de retraso desde el clic
|
||||
setInterval(cargarSnapshot, 1000);
|
||||
|
||||
// ── Despertador SSE ──
|
||||
// Esta pantalla corre en la ventana de un computador de uso mixto: puede
|
||||
// quedar tapada o minimizada, y Chrome congela los relojes de las pestañas
|
||||
// ocultas (~1 tic/min). El sondeo de arriba se paraliza y los llamados no
|
||||
// suenan. Los eventos de red NO se congelan: el servidor emite cola_update en
|
||||
// cada llamado y rellamado (sse_ping_at), y ese mensaje despierta la pantalla
|
||||
// aunque esté de fondo. El sondeo queda como respaldo si el SSE se cae; si
|
||||
// ambos fallan, el vigía de congelamiento lo deja anotado en la bitácora.
|
||||
(function conectarSSE() {
|
||||
if (!('EventSource' in window)) return;
|
||||
try {
|
||||
const es = new EventSource(BASE_API + 'sse_turno.php?area=recepcion');
|
||||
es.addEventListener('cola_update', () => { try { cargarSnapshot(); } catch (_) {} });
|
||||
// EventSource se reconecta solo tras un error; no hay nada que hacer aquí
|
||||
} catch (_) {}
|
||||
})();
|
||||
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) cargarSnapshot(); });
|
||||
window.addEventListener('focus', cargarSnapshot);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/views/firma.php
|
||||
* Tablet de firma del paciente — /erp.php?m=turnero&v=firma
|
||||
*
|
||||
* Pantalla de cara al público: se pone frente al paciente en el mostrador y
|
||||
* muestra, sola, el consentimiento de bienvenida cuando le toca firmarlo.
|
||||
* Antes había que girarle el monitor a la recepcionista o pasarle el mouse.
|
||||
*
|
||||
* La tablet se identifica por la cookie del dispositivo, registrada desde
|
||||
* Configuración. Sin ella no muestra nada: es lo que impide que cualquiera
|
||||
* abra esta dirección y vea el nombre del paciente de turno.
|
||||
*
|
||||
* Conviene dejarla en modo kiosco, sin barra de direcciones, para que desde
|
||||
* ella no se pueda navegar al resto del ERP.
|
||||
*/
|
||||
|
||||
$_fCfg = [];
|
||||
try {
|
||||
$__pdo = Database::getInstance()->getConnection();
|
||||
$_fCfg = $__pdo->query(
|
||||
"SELECT clave, valor FROM lab_config WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color')"
|
||||
)->fetchAll(PDO::FETCH_KEY_PAIR) ?: [];
|
||||
} catch (\Throwable $_) {}
|
||||
|
||||
$_fNombre = htmlspecialchars($_fCfg['empresa_nombre'] ?? 'Laboratorio');
|
||||
$_fLogo = $_fCfg['doc_logo_base64'] ?? '';
|
||||
$_fColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_fCfg['doc_color'] ?? '') ? $_fCfg['doc_color'] : '#1565c0';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>Firma · <?= $_fNombre ?></title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root { --brand: <?= $_fColor ?>; }
|
||||
html, body {
|
||||
height: 100%; width: 100%; overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: #f8fafc; color: #1e293b;
|
||||
-webkit-user-select: none; user-select: none;
|
||||
}
|
||||
.pantalla {
|
||||
height: 100vh; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
text-align: center; padding: 4vh 5vw; gap: 2.5vh;
|
||||
}
|
||||
.pantalla.oculta { display: none; }
|
||||
.logo { max-height: 14vh; max-width: 50vw; object-fit: contain; }
|
||||
.marca { font-size: clamp(1.2rem, 3vw, 2rem); font-weight: 700; color: #64748b; }
|
||||
|
||||
.saludo { font-size: clamp(1.4rem, 4vw, 2.6rem); font-weight: 600; color: #94a3b8; }
|
||||
.etiqueta { font-size: clamp(.9rem, 2vw, 1.2rem); font-weight: 700;
|
||||
letter-spacing: 3px; text-transform: uppercase; color: #94a3b8; }
|
||||
.paciente { font-size: clamp(1.8rem, 5.5vw, 3.4rem); font-weight: 800; line-height: 1.15; }
|
||||
.codigo { font-size: clamp(1.1rem, 2.6vw, 1.6rem); font-weight: 700; color: var(--brand); }
|
||||
|
||||
/* Botón deliberadamente enorme: lo va a tocar gente mayor, de pie y de afán */
|
||||
.btn-firmar {
|
||||
margin-top: 2vh;
|
||||
background: var(--brand); color: #fff; border: 0;
|
||||
border-radius: 18px; cursor: pointer;
|
||||
font-family: inherit; font-weight: 800;
|
||||
font-size: clamp(1.5rem, 4.5vw, 2.6rem);
|
||||
padding: clamp(1rem, 3.5vh, 2.2rem) clamp(2.5rem, 12vw, 6rem);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.18);
|
||||
transition: transform .12s ease, filter .12s ease;
|
||||
}
|
||||
.btn-firmar:active { transform: scale(.96); filter: brightness(.92); }
|
||||
|
||||
.ok-icono { font-size: clamp(3.5rem, 12vw, 7rem); color: #16a34a; line-height: 1; }
|
||||
.ok-txt { font-size: clamp(1.4rem, 4vw, 2.4rem); font-weight: 700; color: #166534; }
|
||||
|
||||
.aviso { font-size: clamp(1rem, 2.4vw, 1.4rem); color: #94a3b8; max-width: 34ch; line-height: 1.5; }
|
||||
|
||||
/* El formulario ocupa toda la pantalla al abrirse */
|
||||
#marco { position: fixed; inset: 0; z-index: 50; background: #fff; display: none; }
|
||||
#marco.abierto { display: block; }
|
||||
#marco iframe { width: 100%; height: 100%; border: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- En reposo: nadie a quien pedirle firma -->
|
||||
<div class="pantalla" id="p-reposo">
|
||||
<?php if ($_fLogo): ?><img class="logo" src="<?= htmlspecialchars($_fLogo) ?>" alt=""><?php endif; ?>
|
||||
<div class="marca"><?= $_fNombre ?></div>
|
||||
<div class="saludo">Bienvenido</div>
|
||||
</div>
|
||||
|
||||
<!-- Hay alguien y le falta firmar -->
|
||||
<div class="pantalla oculta" id="p-firmar">
|
||||
<div class="etiqueta">Turno <span id="f-codigo"></span></div>
|
||||
<div class="paciente" id="f-paciente"></div>
|
||||
<div class="aviso">Por favor lea y firme el consentimiento para continuar con su atención.</div>
|
||||
<button class="btn-firmar" id="btn-firmar">Firmar</button>
|
||||
</div>
|
||||
|
||||
<!-- Ya firmó -->
|
||||
<div class="pantalla oculta" id="p-gracias">
|
||||
<div class="ok-icono">✓</div>
|
||||
<div class="ok-txt">¡Gracias!</div>
|
||||
<div class="aviso">Su consentimiento quedó registrado. Puede continuar en el mostrador.</div>
|
||||
</div>
|
||||
|
||||
<!-- La tablet no está registrada -->
|
||||
<div class="pantalla oculta" id="p-sin-registro">
|
||||
<div class="marca"><?= $_fNombre ?></div>
|
||||
<div class="aviso">Esta tablet todavía no está asignada a un puesto.<br>
|
||||
Regístrela desde Configuración del turnero.</div>
|
||||
</div>
|
||||
|
||||
<div id="marco"><iframe id="marco-iframe" src="about:blank"></iframe></div>
|
||||
|
||||
<script>
|
||||
const API = '<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/';
|
||||
|
||||
let turnoEnPantalla = null; // turno que se está mostrando
|
||||
let firmando = false; // con el formulario abierto no se cambia de pantalla
|
||||
|
||||
function mostrar(id) {
|
||||
['p-reposo','p-firmar','p-gracias','p-sin-registro']
|
||||
.forEach(p => document.getElementById(p).classList.toggle('oculta', p !== id));
|
||||
}
|
||||
|
||||
function abrirFormulario(url) {
|
||||
firmando = true;
|
||||
document.getElementById('marco-iframe').src = url;
|
||||
document.getElementById('marco').classList.add('abierto');
|
||||
}
|
||||
|
||||
function cerrarFormulario() {
|
||||
firmando = false;
|
||||
document.getElementById('marco').classList.remove('abierto');
|
||||
document.getElementById('marco-iframe').src = 'about:blank';
|
||||
}
|
||||
|
||||
document.getElementById('btn-firmar').addEventListener('click', () => {
|
||||
const url = document.getElementById('btn-firmar').dataset.url;
|
||||
if (url) abrirFormulario(url);
|
||||
});
|
||||
|
||||
async function revisar() {
|
||||
// Con el formulario abierto no se toca la pantalla: el paciente está firmando
|
||||
if (firmando) return;
|
||||
|
||||
let d;
|
||||
try {
|
||||
const r = await fetch(API + 'get_firma_pendiente.php', { cache: 'no-store' });
|
||||
d = await r.json();
|
||||
} catch (_) {
|
||||
return; // Sin red se deja lo que haya puesto; ya volverá
|
||||
}
|
||||
|
||||
if (!d.ok) {
|
||||
if (d.motivo === 'sin_dispositivo') mostrar('p-sin-registro');
|
||||
return;
|
||||
}
|
||||
|
||||
if (d.estado === 'por_firmar') {
|
||||
document.getElementById('f-codigo').textContent = d.codigo || '';
|
||||
document.getElementById('f-paciente').textContent = d.paciente || '';
|
||||
document.getElementById('btn-firmar').dataset.url = d.url || '';
|
||||
turnoEnPantalla = d.turno_id;
|
||||
mostrar('p-firmar');
|
||||
return;
|
||||
}
|
||||
|
||||
if (d.estado === 'firmado') {
|
||||
// El "gracias" solo tiene sentido para quien acaba de firmar aquí.
|
||||
// Si la tablet se abre con un turno ya firmado de antes, va a reposo.
|
||||
mostrar(turnoEnPantalla === d.turno_id ? 'p-gracias' : 'p-reposo');
|
||||
return;
|
||||
}
|
||||
|
||||
// reposo: se olvida el turno anterior para no dejar datos de un paciente
|
||||
// en pantalla mientras llega el siguiente
|
||||
turnoEnPantalla = null;
|
||||
mostrar('p-reposo');
|
||||
}
|
||||
|
||||
// ver_formulario_enviado.php avisa al terminar; si no llega el aviso, el sondeo
|
||||
// se encarga igual cuando el consentimiento aparezca como firmado.
|
||||
window.addEventListener('message', (e) => {
|
||||
const t = e.data && e.data.type;
|
||||
if (t === 'turneroFirmado') {
|
||||
cerrarFormulario();
|
||||
mostrar('p-gracias');
|
||||
setTimeout(revisar, 2500);
|
||||
}
|
||||
});
|
||||
|
||||
revisar();
|
||||
setInterval(revisar, 2000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -100,9 +100,24 @@ Layout::open('Historial de Turnos', 'fas fa-history');
|
||||
.detail-extra .d-lbl { font-size:.67rem; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
|
||||
.detail-comment { padding:5px 0; border-bottom:1px solid #f1f5f9; font-size:.82rem; }
|
||||
.detail-comment:last-child { border-bottom:none; }
|
||||
/* ── Origen de cada nota ── */
|
||||
.com-tipo { font-size:.62rem; font-weight:700; letter-spacing:.04em; padding:1px 7px;
|
||||
border-radius:10px; text-transform:uppercase; margin-right:6px; }
|
||||
.com-tipo.recepcion { background:#dbeafe; color:#1d4ed8; }
|
||||
.com-tipo.muestras { background:#dcfce7; color:#15803d; }
|
||||
.com-tipo.general { background:#fef3c7; color:#92400e; }
|
||||
|
||||
|
||||
/* ── Documentos firmados ── */
|
||||
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
|
||||
.traza { border-left:2px solid #e2e8f0; margin:2px 0 0 6px; padding-left:10px; }
|
||||
.traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.78rem; }
|
||||
.traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
|
||||
.traza-ico { width:14px; text-align:center; margin-top:2px; font-size:.72rem; }
|
||||
.traza-txt { flex:1; color:#334155; }
|
||||
.traza-quien { color:#64748b; font-style:italic; }
|
||||
.traza-extra { color:#b45309; font-size:.74rem; margin-top:1px; }
|
||||
.traza-nota { color:#94a3b8; font-size:.7rem; margin-top:6px; font-style:italic; }
|
||||
.docs-lbl { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
|
||||
.doc-pill { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:99px;
|
||||
font-size:.73rem; font-weight:600; margin:2px 2px; text-decoration:none; transition:opacity .15s; }
|
||||
@@ -465,11 +480,19 @@ function renderTabla(turnos) {
|
||||
const bacterio = esc(t.atendido_lugar_nombre || '—');
|
||||
|
||||
// Exámenes: badges con código
|
||||
const examsHtml = (t.examenes && t.examenes.length)
|
||||
? t.examenes.map(e => `<span class="badge bg-success-subtle text-success me-1 mb-1" style="font-size:.65rem">${esc(e.codigo)}</span>`).join('')
|
||||
// Solo el conteo: con 9,5 exámenes de promedio por turno, las etiquetas de
|
||||
// código llenaban cinco renglones y la tabla quedaba ilegible. Los nombres
|
||||
// completos ya están en el detalle desplegable, así que no se pierde nada.
|
||||
const nExam = (t.examenes && t.examenes.length) ? t.examenes.length : 0;
|
||||
const examsHtml = nExam
|
||||
? `<span class="badge bg-success-subtle text-success" style="font-size:.68rem"
|
||||
title="${esc(t.examenes.map(e => e.nombre || e.codigo).join(' · '))}">
|
||||
${nExam} examen${nExam === 1 ? '' : 'es'}
|
||||
</span>`
|
||||
: '<span class="text-muted small">—</span>';
|
||||
|
||||
return `<tr data-id="${t.id}">
|
||||
return `<tr data-id="${t.id}" style="cursor:pointer"
|
||||
onclick="filaDetalle(event, '${rowId}', ${t.id})">
|
||||
<td class="text-nowrap text-muted small">${esc(fecha)}</td>
|
||||
<td><strong style="color:${priCls}">${esc(t.codigo)}</strong></td>
|
||||
<td>
|
||||
@@ -489,19 +512,19 @@ function renderTabla(turnos) {
|
||||
<div class="fw-semibold">${lugar}</div>
|
||||
<div class="text-muted" style="font-size:.72rem"><i class="fas fa-microscope me-1 opacity-50"></i>${bacterio}</div>
|
||||
</td>
|
||||
<td style="max-width:140px">${examsHtml}</td>
|
||||
<td class="text-nowrap">${examsHtml}</td>
|
||||
<td><span class="eb ${cls}">${lbl}</span></td>
|
||||
<td class="text-nowrap">${espMin}</td>
|
||||
<td class="text-nowrap">${srvMin}</td>
|
||||
<td class="text-nowrap">${totMin}</td>
|
||||
<td class="text-nowrap">
|
||||
<button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
|
||||
onclick="toggleDetalle('${rowId}', this, ${t.id})"
|
||||
onclick="event.stopPropagation();toggleDetalle('${rowId}', this, ${t.id})"
|
||||
title="Ver detalles">
|
||||
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-warning py-0 px-2"
|
||||
onclick="abrirModalEstado(${t.id}, '${t.estado}', '${nombre}')"
|
||||
onclick="event.stopPropagation();abrirModalEstado(${t.id}, '${t.estado}', '${nombre}')"
|
||||
title="Cambiar estado">
|
||||
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
|
||||
</button>
|
||||
@@ -567,9 +590,10 @@ function renderDetalle(t) {
|
||||
let comHtml = '';
|
||||
if (t.comentarios && t.comentarios.length) {
|
||||
comHtml = `<div class="detail-extra">
|
||||
<div class="d-lbl"><i class="fas fa-comments me-1 text-primary"></i>Comentarios</div>
|
||||
<div class="d-lbl"><i class="fas fa-comments me-1 text-primary"></i>Notas y observaciones (${t.comentarios.length})</div>
|
||||
<div>${t.comentarios.map(c => `
|
||||
<div class="detail-comment">
|
||||
<span class="com-tipo ${esc(c.tipo||'general')}">${({recepcion:'Recepción',muestras:'Toma de muestras',general:'General'})[c.tipo]||c.tipo}</span>
|
||||
<span class="fw-semibold">${esc(c.usuario_nombre)}</span>
|
||||
<span class="text-muted small ms-1">${c.creado_at ? new Date(c.creado_at.replace(' ','T')).toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'}) : ''}</span>
|
||||
<div class="text-secondary">${esc(c.comentario)}</div>
|
||||
@@ -586,12 +610,32 @@ function renderDetalle(t) {
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
${relHtml}${examHtml}${comHtml}
|
||||
<div class="docs-section" id="traza-${t.id}">
|
||||
<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>
|
||||
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
|
||||
</div>
|
||||
<div class="docs-section" id="docs-${t.id}">
|
||||
<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div>
|
||||
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clic en cualquier parte de la fila: abre o cierra su detalle.
|
||||
*
|
||||
* Se ignora si el usuario estaba seleccionando texto —copiar un documento o un
|
||||
* nombre es frecuente aquí y sería molesto que la fila se abriera al soltar— y
|
||||
* si el clic cayó sobre un enlace o un botón, que tienen lo suyo que hacer.
|
||||
*/
|
||||
function filaDetalle(ev, rowId, turnoId) {
|
||||
if (ev.target.closest('button, a, input, select')) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.toString().length > 0) return;
|
||||
const fila = document.querySelector(`tr[data-id="${turnoId}"]`);
|
||||
const btn = fila ? fila.querySelector('button[title="Ver detalles"]') : null;
|
||||
if (btn) toggleDetalle(rowId, btn, turnoId);
|
||||
}
|
||||
|
||||
function toggleDetalle(rowId, btn, turnoId) {
|
||||
const row = document.getElementById(rowId);
|
||||
const icon = btn.querySelector('i');
|
||||
@@ -606,11 +650,79 @@ function toggleDetalle(rowId, btn, turnoId) {
|
||||
docsEl.dataset.loaded = '1';
|
||||
cargarDocumentosTurno(turnoId, docsEl);
|
||||
}
|
||||
const trazaEl = document.getElementById(`traza-${turnoId}`);
|
||||
if (trazaEl && !trazaEl.dataset.loaded) {
|
||||
trazaEl.dataset.loaded = '1';
|
||||
cargarTrazaTurno(turnoId, trazaEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URL_ROOT = '<?= BASE_URL ?>';
|
||||
|
||||
// ── Bitácora del turno ────────────────────────────────────────
|
||||
// Junta en una sola línea de tiempo lo que antes estaba repartido: el recorrido
|
||||
// del turno, los documentos, los comentarios del personal, las muestras y las
|
||||
// acciones administrativas auditadas.
|
||||
const TRAZA_ICONO = {
|
||||
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
|
||||
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
|
||||
doc_ok:'fa-file-signature', comentario:'fa-comment', alerta:'fa-triangle-exclamation',
|
||||
admin:'fa-user-shield',
|
||||
};
|
||||
const TRAZA_COLOR = {
|
||||
alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb',
|
||||
fin:'#0f766e', admin:'#7c3aed',
|
||||
};
|
||||
|
||||
async function cargarTrazaTurno(turnoId, container) {
|
||||
const titulo = '<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>';
|
||||
try {
|
||||
const res = await fetch(`${API}get_traza_turno.php?turno_id=${turnoId}`);
|
||||
const json = await res.json();
|
||||
if (!json.ok) { container.innerHTML = titulo + '<span class="docs-empty">No se pudo cargar</span>'; return; }
|
||||
|
||||
const ev = json.eventos || [];
|
||||
if (!ev.length) { container.innerHTML = titulo + '<span class="docs-empty">Sin registros</span>'; return; }
|
||||
|
||||
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
|
||||
const filas = ev.map(e => {
|
||||
const color = TRAZA_COLOR[e.tipo] || '#94a3b8';
|
||||
const ico = TRAZA_ICONO[e.tipo] || 'fa-circle';
|
||||
const quien = e.quien ? `<span class="traza-quien">${esc(e.quien)}</span>` : '';
|
||||
const extra = e.extra ? `<div class="traza-extra">${esc(e.extra)}</div>` : '';
|
||||
return `<div class="traza-fila">
|
||||
<span class="traza-hora">${hora(e.cuando)}</span>
|
||||
<i class="fas ${ico} traza-ico" style="color:${color}"></i>
|
||||
<div class="traza-txt"><span>${esc(e.texto)}</span> ${quien}${extra}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// El chat no se vuelca aquí: son datos personales del paciente y esta es
|
||||
// una pantalla de consulta. Se indica que existe y se enlaza.
|
||||
const chat = json.chat
|
||||
? `<div class="traza-fila">
|
||||
<span class="traza-hora"></span>
|
||||
<i class="fab fa-whatsapp traza-ico" style="color:#25d366"></i>
|
||||
<div class="traza-txt">
|
||||
<a href="erp.php?m=turnero&v=chat&user_id=${json.chat.user_id}" target="_blank">
|
||||
${json.chat.mensajes} mensaje(s) de WhatsApp ese día — ver conversación
|
||||
</a>
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
// Se dice qué NO quedó registrado, para que la ausencia de un evento no
|
||||
// se lea como prueba de que no ocurrió.
|
||||
const nota = (json.sinRastro && json.sinRastro.length)
|
||||
? `<div class="traza-nota">El sistema aún no deja rastro de: ${json.sinRastro.map(esc).join(' · ')}.</div>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = titulo + `<div class="traza">${filas}${chat}</div>` + nota;
|
||||
} catch (_) {
|
||||
container.innerHTML = titulo + '<span class="docs-empty">Error al cargar</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function cargarDocumentosTurno(turnoId, container) {
|
||||
try {
|
||||
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
|
||||
|
||||
+237
-10
@@ -9,6 +9,9 @@ if (!isUserLoggedIn()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../_acceso.php';
|
||||
turneroExigirRol(['bacteriologo', 'supervisor']);
|
||||
|
||||
// Tablet asignada → forzar su lugar por token de navegador (o IP como fallback)
|
||||
$lugarForzado = 0;
|
||||
try {
|
||||
@@ -44,9 +47,26 @@ try {
|
||||
if ($_dispRow['tipo'] === 'recepcion') {
|
||||
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_forzado); exit;
|
||||
}
|
||||
if ((int)($_GET['lugar_id'] ?? 0) !== $_forzado) {
|
||||
$_pedido = (int)($_GET['lugar_id'] ?? 0);
|
||||
|
||||
// Un equipo fijo queda atado a su estación, pero algunas —Pediatría,
|
||||
// Ginecología— se atienden desde cualquier puesto: se marcan como de
|
||||
// acceso libre y no se redirige.
|
||||
$_libre = false;
|
||||
if ($_pedido && $_pedido !== $_forzado) {
|
||||
$_sl = $_dispPdo->prepare(
|
||||
"SELECT acceso_libre FROM turnero_lugares WHERE id = ? AND activo = 1 LIMIT 1"
|
||||
);
|
||||
$_sl->execute([$_pedido]);
|
||||
$_libre = (bool)$_sl->fetchColumn();
|
||||
}
|
||||
|
||||
if ($_pedido !== $_forzado && !$_libre) {
|
||||
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_forzado); exit;
|
||||
}
|
||||
// El equipo sigue atado a su estación aunque esté atendiendo una de
|
||||
// acceso libre: la vista muestra la pedida (sale de lugar_id), pero el
|
||||
// botón de cambiar sigue oculto y no puede saltar a cualquier otra.
|
||||
$lugarForzado = $_forzado;
|
||||
}
|
||||
} catch (\Throwable $_) {}
|
||||
@@ -520,6 +540,15 @@ try {
|
||||
}
|
||||
/* Historial timeline */
|
||||
.mpac-timeline { padding: 0; }
|
||||
.mpac-lbl { font-size:.7rem; color:#64748b; font-weight:600; display:block; margin-bottom:2px; }
|
||||
.mpac-aviso { font-size:.74rem; color:#1e40af; background:#eff6ff; border:1px solid #bfdbfe;
|
||||
border-radius:8px; padding:7px 10px; margin-bottom:10px; }
|
||||
.mpac-lock { background:#fffbeb; border:1px solid #fde68a; border-radius:8px;
|
||||
padding:9px 11px; margin:4px 0 10px; }
|
||||
.mpac-lock-txt { font-size:.72rem; color:#92400e; margin-bottom:7px; line-height:1.45; }
|
||||
.mpac-aviso-firmados { font-size:.74rem; color:#7f1d1d; background:#fef2f2;
|
||||
border:1px solid #fecaca; border-radius:8px;
|
||||
padding:8px 11px; margin-bottom:10px; line-height:1.45; }
|
||||
.mtl-item {
|
||||
display:flex; gap:10px; align-items:flex-start;
|
||||
padding-bottom:12px; position:relative;
|
||||
@@ -949,6 +978,87 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
|
||||
<div id="mpac-badge-emb" class="d-none mt-1">
|
||||
<span class="badge text-bg-danger"><i class="fas fa-baby me-1"></i>Paciente embarazada</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="mpacEditar(true)"
|
||||
id="mpac-btn-editar" style="font-size:.74rem">
|
||||
<i class="fas fa-pen me-1"></i>Corregir datos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Edición de la ficha ── -->
|
||||
<div id="mpac-form" class="d-none">
|
||||
<div class="mpac-aviso">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Lo que corrija aquí cambia la ficha del paciente <b>en todo el sistema</b>.
|
||||
</div>
|
||||
|
||||
<label class="mpac-lbl">Celular</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-tel" placeholder="3001234567">
|
||||
|
||||
<label class="mpac-lbl">Dirección</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-dir">
|
||||
|
||||
<label class="mpac-lbl">EPS</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-eps">
|
||||
|
||||
<label class="mpac-lbl">Sexo</label>
|
||||
<select class="form-select form-select-sm mb-2" id="mpac-in-genero">
|
||||
<option value="">— Sin registrar —</option>
|
||||
<option value="M">Masculino</option>
|
||||
<option value="F">Femenino</option>
|
||||
<option value="O">Otro</option>
|
||||
</select>
|
||||
|
||||
<!-- Datos de identidad: requieren un paso deliberado -->
|
||||
<div id="mpac-identidad-lock" class="mpac-lock">
|
||||
<div class="mpac-lock-txt">
|
||||
<i class="fas fa-lock me-1"></i>
|
||||
Nombre, documento y fecha de nacimiento identifican al paciente en toda su
|
||||
historia clínica.
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-warning w-100" onclick="mpacDesbloquearIdentidad()"
|
||||
style="font-size:.74rem">
|
||||
<i class="fas fa-unlock me-1"></i>Corregir también estos datos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="mpac-identidad" class="d-none">
|
||||
<label class="mpac-lbl">Nombre completo</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-nombre">
|
||||
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<div style="width:90px">
|
||||
<label class="mpac-lbl">Tipo</label>
|
||||
<select class="form-select form-select-sm" id="mpac-in-tipodoc">
|
||||
<option>CC</option><option>TI</option><option>RC</option>
|
||||
<option>CE</option><option>PA</option><option>NIT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<label class="mpac-lbl">Nº documento</label>
|
||||
<input type="text" class="form-control form-control-sm" id="mpac-in-doc">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="mpac-lbl">Fecha de nacimiento</label>
|
||||
<input type="date" class="form-control form-control-sm mb-2" id="mpac-in-fnac">
|
||||
</div>
|
||||
|
||||
<div id="mpac-aviso-firmados" class="mpac-aviso-firmados d-none">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>
|
||||
Este turno ya tiene documentos firmados. Corregir la ficha
|
||||
<b>no modifica lo que ya se firmó</b>: si un documento salió con datos
|
||||
equivocados, hay que emitirlo de nuevo.
|
||||
</div>
|
||||
|
||||
<div id="mpac-msg" class="small mb-2"></div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary flex-grow-1" onclick="mpacGuardarFicha()" id="mpac-btn-guardar">
|
||||
<i class="fas fa-save me-1"></i>Guardar
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="mpacEditar(false)">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Historial -->
|
||||
<div class="mpac-sec-title"><i class="fas fa-history" style="color:#6366f1"></i>Historial de visitas</div>
|
||||
@@ -1253,9 +1363,11 @@ async function seleccionarSinLlamar(turnoId) {
|
||||
mostrarCabeceraTurno(t);
|
||||
await cargarFichaSolicitud(t.id);
|
||||
clearInterval(pollingConsentId);
|
||||
if (!_esSoloEntrega(t))
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(t))
|
||||
// También para solo entrega de muestras: a esos turnos les corresponde el
|
||||
// formulario del puesto, así que la lista tiene que refrescarse igual o al
|
||||
// firmar no se vería el cambio.
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
|
||||
cargarFormEmbebido(t.id);
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
@@ -1298,9 +1410,8 @@ async function abrirFicha(turno) {
|
||||
mostrarCabeceraTurno(turno);
|
||||
await cargarFichaSolicitud(turno.id);
|
||||
clearInterval(pollingConsentId);
|
||||
if (!_esSoloEntrega(turno))
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(turno))
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
|
||||
cargarFormEmbebido(turno.id);
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
@@ -1366,10 +1477,15 @@ async function cargarFichaSolicitud(turnoId) {
|
||||
const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
|
||||
document.getElementById('badge-solo-muestras')?.classList.toggle('d-none', !esSoloMuestras);
|
||||
|
||||
// Ocultar sección consentimientos y formulario embebido cuando es solo entrega
|
||||
// La sección de formularios ya NO se esconde por ser solo entrega de
|
||||
// muestras. Esos turnos no traen exámenes, pero sí les corresponde el
|
||||
// formulario del puesto (F-LAB-08, Datos Toma de Muestras): el backend
|
||||
// se lo venía creando y la interfaz lo ocultaba, así que se acumulaban
|
||||
// creados y sin firmar. Quién se muestra lo decide renderConsentimientos
|
||||
// según lo que realmente haya que llenar.
|
||||
const secConsent = document.getElementById('sec-consent');
|
||||
const secForm = document.getElementById('sec-form-embebido');
|
||||
if (secConsent) secConsent.classList.toggle('d-none', esSoloMuestras);
|
||||
if (secConsent) secConsent.classList.remove('d-none');
|
||||
if (secForm) secForm.classList.add('d-none');
|
||||
|
||||
// Embarazada
|
||||
@@ -1917,6 +2033,14 @@ function _mpParentCancelCd() {
|
||||
}
|
||||
|
||||
window.addEventListener('message', function(e) {
|
||||
// El formulario embebido pide corregir la ficha del paciente: se abre el
|
||||
// modal directamente en modo edición, sin cerrar el formulario.
|
||||
if (e.data && e.data.type === 'corregirPaciente') {
|
||||
if (!_pacienteActivo) return;
|
||||
abrirModalPaciente();
|
||||
mpacEditar(true);
|
||||
return;
|
||||
}
|
||||
if (e.data && e.data.type === 'tomaProgresivaIniciada') {
|
||||
_tomaProgresivaActiva = true;
|
||||
if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
|
||||
@@ -2120,7 +2244,7 @@ function resetFicha() {
|
||||
_muestrasActivas = [];
|
||||
const secMuestras = document.getElementById('sec-muestras');
|
||||
if (secMuestras) secMuestras.classList.add('d-none');
|
||||
// Restaurar sección consent (puede haber sido ocultada por turno solo-muestras)
|
||||
// Dejar la sección visible para la siguiente ficha
|
||||
document.getElementById('sec-consent')?.classList.remove('d-none');
|
||||
document.getElementById('badge-solo-muestras')?.classList.add('d-none');
|
||||
document.getElementById('ficha-orden').classList.add('d-none');
|
||||
@@ -2386,6 +2510,109 @@ function abrirModalPaciente() {
|
||||
|
||||
function cerrarModalPaciente() {
|
||||
document.getElementById('modal-pac-lugar').classList.remove('show');
|
||||
mpacEditar(false);
|
||||
}
|
||||
|
||||
/* ── Corrección de la ficha del paciente desde la estación ──
|
||||
Los campos del formulario son vinculados: leen de la ficha. Por eso se
|
||||
corrige la ficha y no el documento — así el dato queda bien en el turnero,
|
||||
los domicilios y las órdenes, no solo en la hoja que se está llenando. */
|
||||
|
||||
function mpacEditar(abrir) {
|
||||
document.getElementById('mpac-form').classList.toggle('d-none', !abrir);
|
||||
document.getElementById('mpac-datos').classList.toggle('d-none', abrir);
|
||||
document.getElementById('mpac-msg').textContent = '';
|
||||
|
||||
if (!abrir) { mpacBloquearIdentidad(); return; }
|
||||
|
||||
const p = _pacienteActivo || {};
|
||||
document.getElementById('mpac-in-tel').value = p.telefono || p.celular || '';
|
||||
document.getElementById('mpac-in-dir').value = p.direccion || '';
|
||||
document.getElementById('mpac-in-eps').value = p.eps || '';
|
||||
document.getElementById('mpac-in-genero').value = p.genero || '';
|
||||
document.getElementById('mpac-in-nombre').value = p.nombre_completo || '';
|
||||
document.getElementById('mpac-in-tipodoc').value = p.tipo_documento || 'CC';
|
||||
document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || '';
|
||||
document.getElementById('mpac-in-fnac').value = (p.fecha_nacimiento || '').slice(0, 10);
|
||||
|
||||
mpacBloquearIdentidad();
|
||||
|
||||
// Si ya hay documentos firmados, advertir que corregir no los modifica.
|
||||
// El botón "Ver firmado" solo se renderiza para consentimientos ya firmados.
|
||||
const firmados = document.querySelectorAll('#lista-consent button[title="Ver firmado"]').length > 0;
|
||||
document.getElementById('mpac-aviso-firmados').classList.toggle('d-none', !firmados);
|
||||
}
|
||||
|
||||
function mpacBloquearIdentidad() {
|
||||
document.getElementById('mpac-identidad').classList.add('d-none');
|
||||
document.getElementById('mpac-identidad-lock').classList.remove('d-none');
|
||||
}
|
||||
|
||||
function mpacDesbloquearIdentidad() {
|
||||
if (!confirm('Nombre, documento y fecha de nacimiento identifican al paciente en toda su '
|
||||
+ 'historia clínica.\n\n¿Confirma que quiere corregirlos?')) return;
|
||||
document.getElementById('mpac-identidad').classList.remove('d-none');
|
||||
document.getElementById('mpac-identidad-lock').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function mpacGuardarFicha() {
|
||||
if (!_pacienteActivo?.id) return;
|
||||
const msg = document.getElementById('mpac-msg');
|
||||
const btn = document.getElementById('mpac-btn-guardar');
|
||||
const val = id => document.getElementById(id).value.trim();
|
||||
|
||||
const cuerpo = {
|
||||
id: _pacienteActivo.id,
|
||||
telefono: val('mpac-in-tel'),
|
||||
direccion: val('mpac-in-dir'),
|
||||
eps: val('mpac-in-eps'),
|
||||
genero: val('mpac-in-genero'),
|
||||
};
|
||||
// Los datos de identidad solo se envían si se desbloquearon
|
||||
if (!document.getElementById('mpac-identidad').classList.contains('d-none')) {
|
||||
cuerpo.nombre_completo = val('mpac-in-nombre');
|
||||
cuerpo.tipo_documento = val('mpac-in-tipodoc');
|
||||
cuerpo.numero_documento = val('mpac-in-doc');
|
||||
cuerpo.fecha_nacimiento = val('mpac-in-fnac') || null;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
msg.className = 'small mb-2 text-muted';
|
||||
msg.textContent = 'Guardando…';
|
||||
|
||||
try {
|
||||
const r = await fetch(`${BASE_WA}api/lab/save_paciente.php`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cuerpo),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.ok) {
|
||||
msg.className = 'small mb-2 text-danger';
|
||||
msg.textContent = d.error || 'No se pudo guardar.';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
// Reflejar el cambio sin recargar: ficha del modal, tarjeta del turno
|
||||
// y el formulario abierto, cuyos campos vinculados leen de la ficha.
|
||||
Object.assign(_pacienteActivo, cuerpo);
|
||||
msg.className = 'small mb-2 text-success';
|
||||
msg.textContent = 'Ficha actualizada.';
|
||||
setTimeout(() => {
|
||||
mpacEditar(false);
|
||||
abrirModalPaciente();
|
||||
if (turnoActivo?.id) actualizarConsentimientos(turnoActivo.id);
|
||||
// Recargar el formulario abierto para que sus campos vinculados
|
||||
// tomen el dato corregido. Puede estar en el modal o embebido.
|
||||
['modal-consent-iframe', 'form-embebido-iframe'].forEach(function (id) {
|
||||
const marco = document.getElementById(id);
|
||||
if (marco && marco.src) marco.src = marco.src;
|
||||
});
|
||||
}, 700);
|
||||
} catch (_) {
|
||||
msg.className = 'small mb-2 text-danger';
|
||||
msg.textContent = 'Error de conexión.';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cargarHistorialModal(pacienteId) {
|
||||
|
||||
@@ -9,6 +9,9 @@ if (!isUserLoggedIn()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../_acceso.php';
|
||||
turneroExigirRol(['recepcionista', 'lab_recepcion', 'supervisor']);
|
||||
|
||||
// Tablet asignada → forzar su escritorio por token de navegador (o IP como fallback)
|
||||
$_recepForzado = 0;
|
||||
try {
|
||||
@@ -736,7 +739,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="examTS && examTS.clear()">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="limpiarExamenes()">
|
||||
<i class="fas fa-times me-1"></i>Limpiar selección
|
||||
</button>
|
||||
</div>
|
||||
@@ -1168,6 +1171,19 @@ function _actualizarContadorExamenes() {
|
||||
badge.classList.toggle('d-none', n === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia la selección de exámenes dejando contador y precios en sincronía.
|
||||
* examTS.clear() quita los ítems en modo silencioso, así que no dispara
|
||||
* onItemRemove: sin esto la insignia conserva el número anterior y el panel
|
||||
* de precios sigue mostrando exámenes que ya no están seleccionados.
|
||||
*/
|
||||
function limpiarExamenes() {
|
||||
if (!examTS) return;
|
||||
examTS.clear();
|
||||
_actualizarContadorExamenes();
|
||||
recalcularPrecios();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
examTS = new TomSelect('#sel-examenes', {
|
||||
plugins: ['remove_button'],
|
||||
@@ -1537,7 +1553,7 @@ function abrirFicha(turno) {
|
||||
.catch(() => {});
|
||||
} else if (turno.paciente_nombre && /^\d{5,15}$/.test(turno.paciente_nombre.trim())) {
|
||||
// El kiosko capturó una cédula como nombre — consultar RIPS de inmediato
|
||||
consultarExamenesRips(turno.paciente_nombre.trim());
|
||||
iniciarSondeoRips(turno.paciente_nombre.trim());
|
||||
}
|
||||
|
||||
// Cargar consentimientos del desk actual para este turno
|
||||
@@ -1657,7 +1673,7 @@ function seleccionarPaciente(pac) {
|
||||
|
||||
// Consultar exámenes recientes en RIPS (últimos 5 min)
|
||||
const cedula = (pac.numero_documento || pac.documento || '').toString().trim();
|
||||
if (cedula) consultarExamenesRips(cedula);
|
||||
if (cedula) iniciarSondeoRips(cedula);
|
||||
filtrarExamenesPorGenero();
|
||||
}
|
||||
|
||||
@@ -1680,6 +1696,31 @@ function filtrarExamenesPorGenero() {
|
||||
// ── Exámenes desde RIPS ───────────────────────────────────────
|
||||
let _ripsData = null;
|
||||
|
||||
// ── Sondeo RIPS ──
|
||||
// La facturación en el sistema del laboratorio ocurre MIENTRAS la
|
||||
// recepcionista atiende, así que consultar una sola vez al vincular al
|
||||
// paciente casi siempre llega temprano: en 7 días, 547 de 549 registros RIPS
|
||||
// aparecieron DESPUÉS de esa única consulta, y nadie volvía a preguntar. Eso
|
||||
// es lo que se percibía como "RIPS se demora". Se sondea cada 10 s hasta 10
|
||||
// minutos, y se detiene al encontrar, al limpiar la ficha o al crear el turno.
|
||||
let _ripsSondeoId = null;
|
||||
let _ripsSondeoQuedan = 0;
|
||||
|
||||
function iniciarSondeoRips(cedula) {
|
||||
detenerSondeoRips();
|
||||
if (!cedula) return;
|
||||
_ripsSondeoQuedan = 60; // 60 intentos × 10 s = 10 min
|
||||
consultarExamenesRips(cedula); // el primero, de inmediato
|
||||
_ripsSondeoId = setInterval(() => {
|
||||
if (--_ripsSondeoQuedan <= 0 || _ripsData) { detenerSondeoRips(); return; }
|
||||
consultarExamenesRips(cedula);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function detenerSondeoRips() {
|
||||
if (_ripsSondeoId) { clearInterval(_ripsSondeoId); _ripsSondeoId = null; }
|
||||
}
|
||||
|
||||
async function consultarExamenesRips(cedula) {
|
||||
_ripsData = null;
|
||||
document.getElementById('banner-rips').classList.add('d-none');
|
||||
@@ -1688,6 +1729,15 @@ async function consultarExamenesRips(cedula) {
|
||||
const r = await fetch(`${BASE_WA}api/lab/get_examenes_rips.php?cedula=${encodeURIComponent(cedula)}`);
|
||||
const d = await r.json();
|
||||
if (!d.ok || !d.encontrados?.length) return;
|
||||
|
||||
// La respuesta pudo llegar DESPUÉS de que la recepcionista cambió de
|
||||
// ficha: sin esta comprobación, los exámenes de un paciente se
|
||||
// cargarían en la ficha del siguiente. Si la cédula ya no corresponde
|
||||
// a quien está en pantalla, la respuesta se descarta.
|
||||
const cedulaEnPantalla = (pacienteActivo?.numero_documento
|
||||
|| pacienteActivo?.documento
|
||||
|| turnoActivo?.paciente_nombre || '').toString().trim();
|
||||
if (cedulaEnPantalla !== cedula) { detenerSondeoRips(); return; }
|
||||
_ripsData = d;
|
||||
const hora = d.hora ? ' (' + String(d.hora).slice(0, 5) + ')' : '';
|
||||
const warn = document.getElementById('banner-rips-warn');
|
||||
@@ -1699,7 +1749,9 @@ async function consultarExamenesRips(cedula) {
|
||||
}
|
||||
// Si los exámenes vienen del cache local (enviados por RIPS scheduler) se cargan
|
||||
// automáticamente. Si vienen del pull en vivo se muestra el banner para confirmación.
|
||||
if (d.fuente === 'cache') {
|
||||
detenerSondeoRips();
|
||||
const yaHayManuales = examTS && examTS.items && examTS.items.length > 0;
|
||||
if (d.fuente === 'cache' && !yaHayManuales) {
|
||||
await cargarExamenesRips();
|
||||
} else {
|
||||
document.getElementById('banner-rips-txt').textContent =
|
||||
@@ -1711,7 +1763,10 @@ async function consultarExamenesRips(cedula) {
|
||||
|
||||
async function cargarExamenesRips() {
|
||||
if (!_ripsData?.encontrados?.length || !examTS) return;
|
||||
// Se agregan en modo silencioso para no recalcular precios en cada examen;
|
||||
// el recálculo y el contador se hacen una sola vez al terminar.
|
||||
_ripsData.encontrados.forEach(e => examTS.addItem(String(e.exam_tipo_id), true));
|
||||
_actualizarContadorExamenes();
|
||||
if (_ripsData.diagnostico_cod) {
|
||||
document.getElementById('inp-diag').value = _ripsData.diagnostico_cod;
|
||||
}
|
||||
@@ -1741,6 +1796,7 @@ async function cargarExamenesRips() {
|
||||
}
|
||||
|
||||
function descartarRips() {
|
||||
detenerSondeoRips();
|
||||
_ripsData = null;
|
||||
document.getElementById('banner-rips').classList.add('d-none');
|
||||
}
|
||||
@@ -2291,12 +2347,32 @@ async function pasarALugar() {
|
||||
// ── Ausente ───────────────────────────────────────────────────
|
||||
async function marcarAusente() {
|
||||
if (!turnoActivo) return;
|
||||
if (!confirm(`¿Marcar turno ${turnoActivo.codigo} como AUSENTE?`)) return;
|
||||
|
||||
// El motivo se guarda como comentario de recepción: así aparece en la
|
||||
// bandeja, el historial y el dashboard sin duplicar el dato.
|
||||
const motivo = prompt(
|
||||
`Marcar el turno ${turnoActivo.codigo} como AUSENTE.\n\n`
|
||||
+ '¿Por qué? (no respondió al llamado, se retiró, reprogramó…)');
|
||||
if (motivo === null) return; // canceló
|
||||
const motivoLimpio = motivo.trim();
|
||||
if (!motivoLimpio) { mostrarError('Indique el motivo para marcar como ausente.'); return; }
|
||||
|
||||
const turnoId = turnoActivo.id;
|
||||
|
||||
await fetch(API + 'comentarios.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
turno_id: turnoId,
|
||||
comentario: 'Marcado ausente: ' + motivoLimpio,
|
||||
tipo: 'recepcion',
|
||||
}),
|
||||
}).catch(() => {});
|
||||
|
||||
await fetch(API + 'cambiar_estado.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'ausente' }),
|
||||
body: JSON.stringify({ turno_id: turnoId, nuevo_estado: 'ausente' }),
|
||||
});
|
||||
|
||||
turnoActivo = null;
|
||||
@@ -2382,7 +2458,7 @@ function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'
|
||||
function toggleSoloMuestras() {
|
||||
const solo = document.getElementById('chk-solo-muestras').checked;
|
||||
const wrap = document.getElementById('wrap-examenes');
|
||||
if (examTS) { examTS.clear(); solo ? examTS.disable() : examTS.enable(); }
|
||||
if (examTS) { limpiarExamenes(); solo ? examTS.disable() : examTS.enable(); }
|
||||
wrap.classList.toggle('disabled', solo);
|
||||
document.getElementById('bloque-medico').style.display = solo ? 'none' : '';
|
||||
if (solo) quitarMedico();
|
||||
@@ -2390,7 +2466,7 @@ function toggleSoloMuestras() {
|
||||
|
||||
function resetCheckboxes() {
|
||||
document.getElementById('chk-solo-muestras').checked = false;
|
||||
if (examTS) { examTS.clear(); examTS.enable(); }
|
||||
if (examTS) { limpiarExamenes(); examTS.enable(); }
|
||||
document.getElementById('wrap-examenes').classList.remove('disabled');
|
||||
document.getElementById('bloque-medico').style.display = '';
|
||||
descartarRips();
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/views/voces.php
|
||||
* Prueba de voces del televisor — /erp.php?m=turnero&v=voces
|
||||
*
|
||||
* Las voces disponibles no las decide el ERP sino el equipo: dependen del
|
||||
* sistema operativo y del navegador del televisor. Por eso esta página hay que
|
||||
* abrirla EN EL TELEVISOR, no en el computador de la oficina: cada equipo
|
||||
* ofrece una lista distinta.
|
||||
*
|
||||
* Sirve para oír cada voz con el texto real de un llamado y elegir la que
|
||||
* quede. Distingue las locales de las que se bajan de internet, que son las
|
||||
* que se entrecortan cuando el wifi flaquea.
|
||||
*/
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Prueba de voces · Turnero</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 24px;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f172a; color: #e2e8f0;
|
||||
}
|
||||
h1 { font-size: 1.5rem; margin: 0 0 4px; }
|
||||
.sub { color: #94a3b8; font-size: .95rem; margin-bottom: 20px; }
|
||||
.aviso {
|
||||
background: #1e293b; border-left: 4px solid #38bdf8;
|
||||
padding: 12px 16px; border-radius: 6px; margin-bottom: 22px;
|
||||
font-size: .92rem; line-height: 1.5;
|
||||
}
|
||||
.fila {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background: #1e293b; border-radius: 8px;
|
||||
padding: 14px 16px; margin-bottom: 10px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
.fila.elegida { border-color: #38bdf8; background: #1e3a52; }
|
||||
.nom { font-weight: 600; flex: 1; min-width: 0; }
|
||||
.nom small { display: block; font-weight: 400; color: #94a3b8; font-size: .8rem; }
|
||||
.tag {
|
||||
font-size: .72rem; padding: 3px 9px; border-radius: 99px;
|
||||
white-space: nowrap; font-weight: 600;
|
||||
}
|
||||
.local { background: #14532d; color: #86efac; }
|
||||
.remota { background: #7c2d12; color: #fdba74; }
|
||||
.actual { background: #0c4a6e; color: #7dd3fc; }
|
||||
button {
|
||||
background: #0284c7; color: #fff; border: 0;
|
||||
padding: 10px 20px; border-radius: 6px; cursor: pointer;
|
||||
font-size: .95rem; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
button:hover { background: #0369a1; }
|
||||
#vacio { color: #94a3b8; padding: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Prueba de voces</h1>
|
||||
<div class="sub">Ábrala <strong>en el televisor</strong>: cada equipo ofrece voces distintas.</div>
|
||||
|
||||
<div class="aviso">
|
||||
Las <span class="tag local">local</span> salen del propio equipo: nunca se cortan, aunque suenen
|
||||
algo más robóticas.<br>
|
||||
Las <span class="tag remota">de internet</span> suenan más naturales, pero se bajan de los
|
||||
servidores de Google en cada llamado. Con wifi débil se entrecortan o no suenan.
|
||||
<br><br>
|
||||
Oiga las que aparezcan y dígame cuál prefiere.
|
||||
</div>
|
||||
|
||||
<div id="lista"><div id="vacio">Cargando voces…</div></div>
|
||||
|
||||
<script>
|
||||
// El mismo texto que dice la pantalla de turnos, para juzgarlas en condiciones
|
||||
const TEXTO = 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2';
|
||||
|
||||
// Réplica de la preferencia de display_global.php, para señalar cuál está
|
||||
// sonando. Si allá se cambia el orden, hay que cambiarlo aquí también.
|
||||
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
|
||||
|
||||
function vozActual(voces) {
|
||||
if (!voces.length) return null;
|
||||
const locales = voces.filter(v => v.localService);
|
||||
for (const nombre of VOCES_PREFERIDAS) {
|
||||
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
|
||||
if (v) return v;
|
||||
}
|
||||
for (const lang of ['es-MX','es-419','es-US','es-CO']) {
|
||||
const v = locales.find(v => v.lang === lang);
|
||||
if (v) return v;
|
||||
}
|
||||
if (locales.length) return locales[0];
|
||||
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
|
||||
const v = voces.find(v => v.lang === lang);
|
||||
if (v) return v;
|
||||
}
|
||||
return voces[0];
|
||||
}
|
||||
|
||||
function probar(voz) {
|
||||
window.speechSynthesis.cancel();
|
||||
const u = new SpeechSynthesisUtterance(TEXTO);
|
||||
u.voice = voz;
|
||||
u.lang = voz.lang;
|
||||
u.rate = 0.95;
|
||||
u.pitch = 1.05;
|
||||
window.speechSynthesis.speak(u);
|
||||
}
|
||||
|
||||
function pintar() {
|
||||
const todas = window.speechSynthesis.getVoices();
|
||||
const es = todas.filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
|
||||
const lista = document.getElementById('lista');
|
||||
const actual = vozActual(es);
|
||||
|
||||
if (!es.length) {
|
||||
lista.innerHTML = '<div id="vacio">Este equipo no tiene ninguna voz en español instalada. '
|
||||
+ 'Habría que instalarle una desde la configuración del sistema, '
|
||||
+ 'o dejar el llamado solo con el pito y el cartel.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Las locales primero: son las que interesan
|
||||
es.sort((a, b) => (b.localService - a.localService) || a.name.localeCompare(b.name));
|
||||
|
||||
lista.innerHTML = '';
|
||||
es.forEach((v, i) => {
|
||||
const fila = document.createElement('div');
|
||||
fila.className = 'fila' + (v === actual ? ' elegida' : '');
|
||||
fila.innerHTML =
|
||||
'<div class="nom">' + v.name + '<small>' + v.lang + '</small></div>'
|
||||
+ (v.localService ? '<span class="tag local">local</span>'
|
||||
: '<span class="tag remota">de internet</span>')
|
||||
+ (v === actual ? '<span class="tag actual">la de ahora</span>' : '');
|
||||
const b = document.createElement('button');
|
||||
b.textContent = 'Oír';
|
||||
b.onclick = () => probar(v);
|
||||
fila.appendChild(b);
|
||||
lista.appendChild(fila);
|
||||
});
|
||||
}
|
||||
|
||||
// getVoices() suele venir vacío en la primera llamada: el navegador las carga aparte
|
||||
window.speechSynthesis.onvoiceschanged = pintar;
|
||||
pintar();
|
||||
setTimeout(pintar, 600);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
/**
|
||||
* scripts/backfill_bsuid.php
|
||||
*
|
||||
* Rellena users.bsuid a partir del histórico de webhook_logs.
|
||||
*
|
||||
* Meta manda el BSUID en todos los webhooks de mensaje, así que en el histórico
|
||||
* están las equivalencias BSUID↔teléfono de la gente que escribió cuando aún
|
||||
* mostraba su número. Cargarlas significa que, el día que oculten el teléfono,
|
||||
* el bot siga sabiendo quiénes son en vez de tratarlos como desconocidos.
|
||||
*
|
||||
* Es idempotente: se puede correr las veces que haga falta.
|
||||
*
|
||||
* La lectura del histórico tarda más de media hora (son ~230.000 registros de
|
||||
* un servidor remoto), y mantener la conexión abierta todo ese rato la mataba
|
||||
* antes de empezar a escribir: "MySQL server has gone away". Por eso el mapa se
|
||||
* guarda en disco y la escritura usa una conexión nueva. De paso, reintentar
|
||||
* sale gratis: si el mapa ya está en caché, no se vuelve a leer la tabla.
|
||||
*
|
||||
* Uso:
|
||||
* php scripts/backfill_bsuid.php --simular (no escribe, solo informa)
|
||||
* php scripts/backfill_bsuid.php
|
||||
* php scripts/backfill_bsuid.php --releer (ignora la caché y relee el histórico)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
$simular = in_array('--simular', $argv, true);
|
||||
$releer = in_array('--releer', $argv, true);
|
||||
echo $simular ? "Modo simulación: no se escribe nada.\n\n" : "Aplicando cambios.\n\n";
|
||||
|
||||
$cache = sys_get_temp_dir() . '/backfill_bsuid_mapa.json';
|
||||
|
||||
// --- 1. Recorrer el histórico y armar el mapa BSUID → teléfono ---
|
||||
$mapa = [];
|
||||
$sinTelefono = [];
|
||||
|
||||
if (!$releer && is_readable($cache)) {
|
||||
$guardado = json_decode(file_get_contents($cache), true) ?: [];
|
||||
$mapa = $guardado['mapa'] ?? [];
|
||||
$sinTelefono = $guardado['sinTelefono'] ?? [];
|
||||
printf("Mapa tomado de la caché (%s, del %s).\n", basename($cache), date('Y-m-d H:i', filemtime($cache)));
|
||||
printf("Para releer el histórico: --releer\n\n");
|
||||
}
|
||||
|
||||
if (!$mapa) {
|
||||
echo "Leyendo webhook_logs (tarda; es una sola vez)...\n";
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Una sola pasada sin buffer: son más de 400.000 registros, así que ni caben
|
||||
// en memoria de golpe ni conviene reconsultar la tabla por bloques (cada bloque
|
||||
// volvía a recorrerla entera y tardaba una eternidad).
|
||||
$conn = $db->getConnection();
|
||||
$bufferPrevio = $conn->getAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY);
|
||||
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
|
||||
|
||||
$leidos = 0;
|
||||
|
||||
try {
|
||||
$stmt = $conn->query(
|
||||
"SELECT request_body FROM webhook_logs
|
||||
WHERE request_body LIKE '%user_id%' AND request_body LIKE '%\"messages\"%'"
|
||||
);
|
||||
|
||||
while ($fila = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$leidos++;
|
||||
if ($leidos % 50000 === 0) printf(" ...%d registros\n", $leidos);
|
||||
$d = json_decode($fila['request_body'], true);
|
||||
if (!$d) continue;
|
||||
|
||||
foreach ($d['entry'] ?? [] as $entry) {
|
||||
foreach ($entry['changes'] ?? [] as $cambio) {
|
||||
$v = $cambio['value'] ?? [];
|
||||
|
||||
// El teléfono puede venir en contacts[].wa_id o en messages[].from
|
||||
foreach ($v['contacts'] ?? [] as $c) {
|
||||
if (!empty($c['user_id']) && !empty($c['wa_id'])) {
|
||||
$mapa[$c['user_id']] = $c['wa_id'];
|
||||
}
|
||||
}
|
||||
foreach ($v['messages'] ?? [] as $m) {
|
||||
$uid = $m['from_user_id'] ?? null;
|
||||
if (!$uid) continue;
|
||||
if (!empty($m['from'])) $mapa[$uid] = $m['from'];
|
||||
else $sinTelefono[$uid] = true;
|
||||
}
|
||||
|
||||
// Cuando alguien comparte su contacto, el teléfono llega aquí
|
||||
foreach ($v['messages'] ?? [] as $m) {
|
||||
if (($m['type'] ?? '') !== 'contacts') continue;
|
||||
$uid = $m['from_user_id'] ?? null;
|
||||
foreach ($m['contacts'] ?? [] as $c) {
|
||||
foreach ($c['phones'] ?? [] as $t) {
|
||||
$num = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||
if ($uid && $num) $mapa[$uid] = preg_replace('/[^0-9]/', '', $num);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$stmt->closeCursor();
|
||||
} finally {
|
||||
// Dejar la conexión como estaba
|
||||
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $bufferPrevio);
|
||||
}
|
||||
|
||||
printf("\n registros revisados: %d\n", $leidos);
|
||||
printf(" equivalencias encontradas: %d\n", count($mapa));
|
||||
file_put_contents($cache, json_encode(['mapa' => $mapa, 'sinTelefono' => $sinTelefono]));
|
||||
printf(" mapa guardado en %s\n", $cache);
|
||||
|
||||
// Se termina aquí a propósito. Tras media hora de lectura el servidor ya cerró
|
||||
// esta conexión, y el singleton de Database no sabe reconectar: escribir ahora
|
||||
// falla con "MySQL server has gone away" a mitad del recorrido, que es justo lo
|
||||
// que pasó la primera vez. La segunda ejecución toma el mapa de la caché en un
|
||||
// segundo y escribe con la conexión sana.
|
||||
echo "\nMapa listo. Ejecute otra vez el comando para aplicarlo.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
printf(" equivalencias encontradas: %d\n", count($mapa));
|
||||
printf(" personas que llegaron sin teléfono: %d\n", count($sinTelefono));
|
||||
|
||||
// --- 1b. Un solo BSUID por teléfono ---
|
||||
// Hay números que aparecen con dos identificadores: la línea cambió de dueño
|
||||
// (reciclada) o la persona se registró de nuevo. Si se recorre el mapa tal cual,
|
||||
// ambos escriben sobre el mismo usuario y el que queda depende del orden: cada
|
||||
// ejecución dejaba un valor distinto. Se resuelve antes de tocar la base,
|
||||
// quedándose con el visto más tarde, y se escribe una sola vez por teléfono.
|
||||
$porTelefono = [];
|
||||
$elegido = [];
|
||||
foreach ($mapa as $b => $t) {
|
||||
$porTelefono[$t][] = $b;
|
||||
$elegido[$t] = $b; // el orden del mapa es el de lectura: el último gana
|
||||
}
|
||||
$ambiguos = array_filter($porTelefono, fn($bs) => count($bs) > 1);
|
||||
printf(" teléfonos distintos: %d\n", count($porTelefono));
|
||||
printf(" teléfonos con más de un BSUID: %d (se toma el más reciente)\n\n", count($ambiguos));
|
||||
foreach (array_slice($ambiguos, 0, 10, true) as $t => $bs) {
|
||||
printf(" %s → %s\n", substr($t, 0, -4) . '****', implode(', ', $bs));
|
||||
}
|
||||
if (count($ambiguos) > 10) printf(" (y %d más)\n", count($ambiguos) - 10);
|
||||
if ($ambiguos) echo "\n";
|
||||
|
||||
// --- 2. Escribir el BSUID en el usuario que corresponde ---
|
||||
$db = Database::getInstance();
|
||||
|
||||
$marcados = 0; $noExisten = 0; $yaEstaban = 0; $conflictos = 0;
|
||||
|
||||
foreach ($elegido as $telefono => $bsuid) {
|
||||
$u = $db->fetch("SELECT id, bsuid FROM users WHERE phone_number = :t", ['t' => $telefono]);
|
||||
if (!$u) { $noExisten++; continue; }
|
||||
if ($u['bsuid'] === $bsuid) { $yaEstaban++; continue; }
|
||||
if (!empty($u['bsuid'])) {
|
||||
printf(" aviso: %s tenía %s y queda con %s\n", $telefono, $u['bsuid'], $bsuid);
|
||||
$conflictos++;
|
||||
}
|
||||
if (!$simular) {
|
||||
try {
|
||||
$db->update('users', ['bsuid' => $bsuid], 'id = ?', [$u['id']]);
|
||||
} catch (Exception $e) {
|
||||
printf(" error en %s: %s\n", $telefono, $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$marcados++;
|
||||
}
|
||||
|
||||
printf("\n usuarios marcados con su BSUID: %d\n", $marcados);
|
||||
printf(" ya lo tenían: %d\n", $yaEstaban);
|
||||
printf(" sin usuario en la base: %d\n", $noExisten);
|
||||
printf(" BSUID reemplazados (cambio de número): %d\n", $conflictos);
|
||||
|
||||
// --- 3. ¿A cuántos de los perdidos rescatamos? ---
|
||||
$rescatables = array_intersect_key($mapa, $sinTelefono);
|
||||
printf("\n de los que llegaron sin teléfono, quedan identificados: %d\n", count($rescatables));
|
||||
foreach ($rescatables as $b => $t) {
|
||||
printf(" %-24s → %s\n", $b, substr($t, 0, -4) . '****');
|
||||
}
|
||||
|
||||
$pendientes = array_diff_key($sinTelefono, $mapa);
|
||||
printf("\n siguen sin identificar: %d (hay que pedirles el contacto)\n", count($pendientes));
|
||||
|
||||
echo $simular ? "\nSimulación terminada, no se escribió nada.\n" : "\nListo.\n";
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
/**
|
||||
* Verificación de la identidad por BSUID (nombres de usuario de WhatsApp).
|
||||
* Ejecutar: php test_bsuid.php
|
||||
*/
|
||||
require_once __DIR__ . '/../services/WhatsAppService.php';
|
||||
|
||||
$fallos = 0;
|
||||
function chequear($descripcion, $condicion) {
|
||||
global $fallos;
|
||||
if ($condicion) { echo " ok $descripcion\n"; }
|
||||
else { echo " FALLA $descripcion\n"; $fallos++; }
|
||||
}
|
||||
|
||||
// --- 1. Reconocer un BSUID y no confundirlo con un teléfono ---
|
||||
chequear('reconoce el BSUID real de hoy', WhatsAppService::esBsuid('CO.1761088155094242'));
|
||||
chequear('reconoce el BSUID largo de Meta', WhatsAppService::esBsuid('US.13491208655302741918'));
|
||||
chequear('un celular NO es BSUID', !WhatsAppService::esBsuid('573214533764'));
|
||||
chequear('vacío NO es BSUID', !WhatsAppService::esBsuid(''));
|
||||
chequear('texto suelto NO es BSUID', !WhatsAppService::esBsuid('CO.abc'));
|
||||
|
||||
// --- 2. formatPhoneNumber no debe destrozar el BSUID ---
|
||||
$svc = (new ReflectionClass('WhatsAppService'))->newInstanceWithoutConstructor();
|
||||
$fmt = new ReflectionMethod('WhatsAppService', 'formatPhoneNumber');
|
||||
$fmt->setAccessible(true);
|
||||
|
||||
chequear('el BSUID sale intacto',
|
||||
$fmt->invoke($svc, 'CO.1761088155094242') === 'CO.1761088155094242');
|
||||
chequear('el celular sin indicativo sigue recibiendo el 57',
|
||||
$fmt->invoke($svc, '3214533764') === '573214533764');
|
||||
chequear('el celular con indicativo se conserva',
|
||||
$fmt->invoke($svc, '573214533764') === '573214533764');
|
||||
|
||||
// --- 3. El payload debe usar `recipient` para BSUID y `to` para teléfonos ---
|
||||
// Se replica la transformación de sendMessage(), que es privada y hace red.
|
||||
$transformar = function (array $payload) {
|
||||
if (isset($payload['to']) && WhatsAppService::esBsuid($payload['to'])) {
|
||||
$payload['recipient'] = $payload['to'];
|
||||
unset($payload['to']);
|
||||
}
|
||||
return $payload;
|
||||
};
|
||||
|
||||
$conBsuid = $transformar(['to' => 'CO.1761088155094242', 'type' => 'text']);
|
||||
chequear('con BSUID se envía en `recipient`', ($conBsuid['recipient'] ?? null) === 'CO.1761088155094242');
|
||||
chequear('con BSUID desaparece `to`', !isset($conBsuid['to']));
|
||||
|
||||
$conTel = $transformar(['to' => '573214533764', 'type' => 'text']);
|
||||
chequear('con teléfono se mantiene `to`', ($conTel['to'] ?? null) === '573214533764');
|
||||
chequear('con teléfono no aparece `recipient`', !isset($conTel['recipient']));
|
||||
|
||||
// --- 4. El webhook debe extraer el remitente del payload real que llegó hoy ---
|
||||
$payloadReal = json_decode('{"messaging_product":"whatsapp",
|
||||
"contacts":[{"profile":{"name":"con la fe puesta en Dios","username":"Luzmarytosfer"},
|
||||
"user_id":"CO.1761088155094242"}],
|
||||
"messages":[{"from_user_id":"CO.1761088155094242","id":"wamid.XXX",
|
||||
"timestamp":"1786481656","text":{"body":"Buenos dias"},"type":"text"}]}', true);
|
||||
|
||||
$m = $payloadReal['messages'][0];
|
||||
$remitente = $m['from'] ?? ($m['wa_id'] ?? ($m['from_user_id'] ?? null));
|
||||
chequear('el mensaje que se perdía ahora sí tiene remitente', $remitente === 'CO.1761088155094242');
|
||||
|
||||
$nombres = [];
|
||||
foreach ($payloadReal['contacts'] as $c) {
|
||||
$n = $c['profile']['name'] ?? null;
|
||||
if (!$n) continue;
|
||||
foreach ([$c['wa_id'] ?? null, $c['user_id'] ?? null] as $k) {
|
||||
if ($k) $nombres[$k] = $n;
|
||||
}
|
||||
}
|
||||
chequear('se rescata el nombre aunque no haya wa_id',
|
||||
($nombres['CO.1761088155094242'] ?? null) === 'con la fe puesta en Dios');
|
||||
|
||||
// --- 5. El identificador debe caber en la columna ---
|
||||
chequear('el BSUID más largo cabe en varchar(32)', strlen('US.13491208655302741918') <= 32);
|
||||
|
||||
// --- 6. Extraer el teléfono del contacto compartido (webhook `contacts` de Meta) ---
|
||||
$extraerTelefono = function (array $mensaje) {
|
||||
foreach ($mensaje['contacts'] ?? [] as $c) {
|
||||
foreach ($c['phones'] ?? [] as $t) {
|
||||
$cand = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||
if ($cand) return preg_replace('/[^0-9]/', '', $cand);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
$porBoton = ['type' => 'contacts', 'from_user_id' => 'CO.1761088155094242',
|
||||
'contacts' => [['origin' => 'contact_request',
|
||||
'phones' => [['phone' => '+57 321 4533764', 'wa_id' => '573214533764', 'type' => 'CELL']]]]];
|
||||
chequear('del botón se extrae el teléfono', $extraerTelefono($porBoton) === '573214533764');
|
||||
|
||||
// Compartido a mano: puede venir con vcard y sin wa_id
|
||||
$aMano = ['type' => 'contacts', 'from_user_id' => 'CO.1761088155094242',
|
||||
'contacts' => [['origin' => 'other', 'vcard' => 'BEGIN:VCARD...',
|
||||
'phones' => [['phone' => '+57 321 453 3764', 'type' => 'CELL']]]]];
|
||||
chequear('compartido a mano también se extrae', $extraerTelefono($aMano) === '573214533764');
|
||||
chequear('sin teléfono devuelve nulo', $extraerTelefono(['type' => 'contacts', 'contacts' => [[]]]) === null);
|
||||
|
||||
// --- 7. El botón para pedir el contacto debe armarse como exige Meta ---
|
||||
$pedir = new ReflectionMethod('WhatsAppService', 'pedirContacto');
|
||||
chequear('pedirContacto existe y es pública', $pedir->isPublic());
|
||||
|
||||
$payloadBoton = [
|
||||
'messaging_product' => 'whatsapp',
|
||||
'recipient_type' => 'individual',
|
||||
'to' => $fmt->invoke($svc, 'CO.1761088155094242'),
|
||||
'type' => 'interactive',
|
||||
'interactive' => ['type' => 'request_contact_info',
|
||||
'body' => ['text' => 'texto'],
|
||||
'action' => ['name' => 'request_contact_info']],
|
||||
];
|
||||
$enviado = $transformar($payloadBoton);
|
||||
chequear('el botón viaja con `recipient` cuando es BSUID',
|
||||
($enviado['recipient'] ?? null) === 'CO.1761088155094242' && !isset($enviado['to']));
|
||||
chequear('el tipo interactivo es el que Meta espera',
|
||||
$enviado['interactive']['type'] === 'request_contact_info'
|
||||
&& $enviado['interactive']['action']['name'] === 'request_contact_info');
|
||||
|
||||
// --- 8. La ficha del paciente no debe recibir el BSUID como teléfono ---
|
||||
// Réplica de la decisión de Paciente::obtenerOCrearDesdeWhatsapp()
|
||||
$fichaDesde = function ($phoneNumber, $nombre = null) {
|
||||
$sinTelefono = esBsuid($phoneNumber);
|
||||
return [
|
||||
'nombre_completo' => $nombre ?? ($sinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $phoneNumber),
|
||||
'telefono' => $sinTelefono ? null : $phoneNumber,
|
||||
];
|
||||
};
|
||||
|
||||
$fichaOculta = $fichaDesde('CO.1761088155094242');
|
||||
chequear('la ficha queda SIN teléfono si es BSUID', $fichaOculta['telefono'] === null);
|
||||
chequear('y sin el identificador metido en el nombre',
|
||||
strpos($fichaOculta['nombre_completo'], 'CO.') === false);
|
||||
|
||||
$fichaNormal = $fichaDesde('573214533764');
|
||||
chequear('con teléfono real la ficha lo conserva', $fichaNormal['telefono'] === '573214533764');
|
||||
chequear('y el nombre por defecto no cambia', $fichaNormal['nombre_completo'] === 'Paciente 573214533764');
|
||||
|
||||
// El daño concreto que se evita: normalizarTelefono() dejaba 16 dígitos con
|
||||
// toda la pinta de un número real dentro de la historia clínica.
|
||||
$comoQuedaba = preg_replace('/[^0-9+]/', '', 'CO.1761088155094242');
|
||||
chequear('antes se guardaba un número falso creíble', $comoQuedaba === '1761088155094242');
|
||||
chequear('y ahora eso ya no llega al campo teléfono', $fichaOculta['telefono'] !== $comoQuedaba);
|
||||
|
||||
// --- 9. Solo se pide el contacto a quien hace falta, y una sola vez ---
|
||||
$decidir = function ($phoneNumber, $pedidoAt) {
|
||||
if (!esBsuid($phoneNumber)) return 'no_hace_falta';
|
||||
if (!empty($pedidoAt)) return 'ya_se_pidio';
|
||||
return 'pedido';
|
||||
};
|
||||
chequear('a quien tiene teléfono no se le pide', $decidir('573214533764', null) === 'no_hace_falta');
|
||||
chequear('a quien lo oculta sí se le pide', $decidir('CO.1761088155094242', null) === 'pedido');
|
||||
chequear('y no se le insiste una segunda vez',
|
||||
$decidir('CO.1761088155094242', '2026-08-11 16:00:00') === 'ya_se_pidio');
|
||||
|
||||
// --- 10. La comprobación vive en un solo sitio ---
|
||||
chequear('esBsuid() global y el del servicio coinciden',
|
||||
esBsuid('CO.1761088155094242') === WhatsAppService::esBsuid('CO.1761088155094242')
|
||||
&& esBsuid('573214533764') === WhatsAppService::esBsuid('573214533764'));
|
||||
|
||||
echo $fallos === 0 ? "\nTodo correcto.\n" : "\n$fallos verificaciones fallaron.\n";
|
||||
exit($fallos === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* scripts/test_rips_sondeo.js — node scripts/test_rips_sondeo.js
|
||||
*
|
||||
* Pruebas del sondeo RIPS contra el código real de recepcion.php.
|
||||
*
|
||||
* El defecto que esto vigila: la consulta a RIPS se hacía UNA vez al vincular
|
||||
* al paciente, y en 7 días 547 de 549 registros llegaron después de esa única
|
||||
* consulta. El sondeo reintenta cada 10 s hasta 10 minutos y debe detenerse
|
||||
* al encontrar, al limpiar la ficha, o al agotar los intentos.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
let fallos = 0;
|
||||
const ok = (d, c) => { console.log((c ? ' ok ' : ' FALLA ') + d); if (!c) fallos++; };
|
||||
|
||||
const src = fs.readFileSync(__dirname + '/../modules/turnero/views/recepcion.php', 'utf8')
|
||||
.replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"');
|
||||
|
||||
// Se extraen exactamente las tres piezas del control de sondeo
|
||||
const m = src.match(/let _ripsSondeoId[\s\S]*?function detenerSondeoRips\(\) \{[\s\S]*?\n\}/);
|
||||
if (!m) { console.log(' FALLA no se encontró el bloque de sondeo'); process.exit(1); }
|
||||
|
||||
function armar() {
|
||||
let consultas = 0;
|
||||
const intervalos = new Map();
|
||||
let proximoId = 1;
|
||||
const setIntervalFake = (fn, ms) => { const id = proximoId++; intervalos.set(id, { fn, ms }); return id; };
|
||||
const clearIntervalFake = (id) => intervalos.delete(id);
|
||||
|
||||
const api = new Function(
|
||||
'setInterval', 'clearInterval', 'INTERVALOS', 'CONTADOR',
|
||||
'let _ripsData = null;' +
|
||||
'const consultarExamenesRips = (c) => { CONTADOR.n++; };' +
|
||||
m[0] + `;
|
||||
return {
|
||||
iniciar: iniciarSondeoRips,
|
||||
detener: detenerSondeoRips,
|
||||
tic() { for (const v of [...INTERVALOS.values()]) v.fn(); },
|
||||
activo() { return _ripsSondeoId !== null; },
|
||||
encontrado() { _ripsData = { encontrados: [1] }; },
|
||||
};`
|
||||
)(setIntervalFake, clearIntervalFake, intervalos, (globalThis.__c = { n: 0 }));
|
||||
return { api, contador: globalThis.__c, intervalos };
|
||||
}
|
||||
|
||||
// ── 1. Consulta de inmediato y reintenta ────────────────────────────────
|
||||
{
|
||||
const { api, contador } = armar();
|
||||
api.iniciar('12345678');
|
||||
ok('consulta de inmediato al vincular', contador.n === 1);
|
||||
api.tic(); api.tic(); api.tic();
|
||||
ok('reintenta en cada ciclo mientras no encuentra', contador.n === 4);
|
||||
}
|
||||
|
||||
// ── 2. Al encontrar, deja de sondear ────────────────────────────────────
|
||||
{
|
||||
const { api, contador } = armar();
|
||||
api.iniciar('12345678');
|
||||
api.tic();
|
||||
api.encontrado();
|
||||
api.tic(); // este ciclo detecta _ripsData y se apaga
|
||||
const n = contador.n;
|
||||
api.tic(); api.tic();
|
||||
ok('al encontrar los exámenes se apaga y no consulta más',
|
||||
contador.n === n && !api.activo());
|
||||
}
|
||||
|
||||
// ── 3. Limpiar la ficha lo detiene ──────────────────────────────────────
|
||||
{
|
||||
const { api, contador } = armar();
|
||||
api.iniciar('12345678');
|
||||
api.detener();
|
||||
const n = contador.n;
|
||||
api.tic(); api.tic();
|
||||
ok('limpiar la ficha detiene el sondeo en seco', contador.n === n && !api.activo());
|
||||
}
|
||||
|
||||
// ── 4. Tope de intentos: no sondea para siempre ─────────────────────────
|
||||
{
|
||||
const { api, contador } = armar();
|
||||
api.iniciar('12345678');
|
||||
for (let i = 0; i < 80; i++) api.tic();
|
||||
ok('se rinde tras ~60 intentos (10 min), no queda sondeando eternamente',
|
||||
contador.n <= 61 && !api.activo());
|
||||
}
|
||||
|
||||
// ── 5. Vincular a otro paciente reinicia sin duplicar intervalos ────────
|
||||
{
|
||||
const { api, contador, intervalos } = armar();
|
||||
api.iniciar('11111111');
|
||||
api.iniciar('22222222');
|
||||
ok('re-vincular no deja dos sondeos corriendo a la vez', intervalos.size === 1);
|
||||
}
|
||||
|
||||
// ── 6. Sin cédula no arranca ────────────────────────────────────────────
|
||||
{
|
||||
const { api, contador } = armar();
|
||||
api.iniciar('');
|
||||
ok('sin cédula no consulta ni deja intervalos', contador.n === 0 && !api.activo());
|
||||
}
|
||||
|
||||
// ── 7. Guardián de ficha: una respuesta tardía no aterriza en otro paciente ──
|
||||
// La respuesta del fetch puede llegar DESPUÉS de que la recepcionista cambió
|
||||
// de ficha. Este caso vigila que el código compare la cédula de la respuesta
|
||||
// contra la de quien está en pantalla ANTES de cargar nada. Es estructural
|
||||
// (el código real usa fetch y DOM), pero si alguien borra el guardián, falla.
|
||||
{
|
||||
const i = src.indexOf('get_examenes_rips.php?cedula=');
|
||||
const j = src.indexOf('cedulaEnPantalla', i);
|
||||
const k = src.indexOf("d.fuente === 'cache'", i);
|
||||
ok('el guardián de cédula existe y corre ANTES de cargar los exámenes',
|
||||
i > -1 && j > -1 && k > -1 && j < k);
|
||||
ok('el guardián descarta y detiene el sondeo si la ficha cambió',
|
||||
/cedulaEnPantalla !== cedula\) \{ detenerSondeoRips\(\); return; \}/.test(src));
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
|
||||
process.exit(fallos === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* scripts/test_rips_ventana.php — php scripts/test_rips_ventana.php
|
||||
*
|
||||
* Prueba la VENTANA de la caché RIPS (la consulta de get_examenes_rips.php)
|
||||
* contra la base real, en una tabla TEMPORARY que se esfuma al desconectar:
|
||||
* ni toca ni ensucia los datos de producción.
|
||||
*
|
||||
* Reglas que vigila:
|
||||
* - un registro de hace 5 minutos se encuentra;
|
||||
* - uno de hace más de 30 minutos NO (ventana);
|
||||
* - uno ya asignado a un turno NO (turno_id);
|
||||
* - uno de ayer NO (solo el día actual);
|
||||
* - de dos del mismo documento gana el más reciente.
|
||||
*/
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
$fallos = 0;
|
||||
function ok(string $d, bool $c): void {
|
||||
global $fallos;
|
||||
echo ($c ? ' ok ' : ' FALLA ') . $d . "\n";
|
||||
if (!$c) $fallos++;
|
||||
}
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
|
||||
$pdo->exec("CREATE TEMPORARY TABLE tmp_rips LIKE rips_examenes_pendientes");
|
||||
|
||||
$ins = $pdo->prepare(
|
||||
"INSERT INTO tmp_rips (numero_documento, datos, recepcion_id, hora_recepcion, created_at, turno_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
// (documento, minutos hacia atrás de created_at, turno_id)
|
||||
$casos = [
|
||||
['1111', 5, null], // reciente y libre → debe aparecer
|
||||
['2222', 45, null], // fuera de la ventana de 30 min
|
||||
['3333', 5, 999], // ya usado por un turno
|
||||
['4444', 1500, null], // de ayer
|
||||
['5555', 20, null], // mismo doc, dos registros…
|
||||
['5555', 3, null], // …gana el más reciente (marcado v2)
|
||||
];
|
||||
foreach ($casos as $i => [$doc, $min, $turno]) {
|
||||
$ins->execute([
|
||||
$doc,
|
||||
json_encode([['cod_examen' => 'T' . $i, 'v' => $i === 5 ? 'v2' : 'v1']]),
|
||||
1000 + $i,
|
||||
'08:00:00',
|
||||
date('Y-m-d H:i:s', time() - $min * 60),
|
||||
$turno,
|
||||
]);
|
||||
}
|
||||
|
||||
// La MISMA consulta de get_examenes_rips.php, apuntada a la tabla temporal
|
||||
$q = $pdo->prepare(
|
||||
"SELECT datos FROM tmp_rips
|
||||
WHERE numero_documento = ?
|
||||
AND DATE(created_at) = CURDATE()
|
||||
AND created_at >= NOW() - INTERVAL 30 MINUTE
|
||||
AND turno_id IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$buscar = function (string $doc) use ($q) {
|
||||
$q->execute([$doc]);
|
||||
$r = $q->fetch(PDO::FETCH_ASSOC);
|
||||
return $r ? json_decode($r['datos'], true) : null;
|
||||
};
|
||||
|
||||
ok('un registro de hace 5 minutos se encuentra', $buscar('1111') !== null);
|
||||
ok('uno de hace 45 minutos queda fuera de la ventana', $buscar('2222') === null);
|
||||
ok('uno ya asignado a un turno no se reutiliza', $buscar('3333') === null);
|
||||
ok('uno de ayer no aparece aunque el documento coincida', $buscar('4444') === null);
|
||||
$r = $buscar('5555');
|
||||
ok('con dos del mismo documento gana el más reciente', ($r[0]['v'] ?? '') === 'v2');
|
||||
|
||||
// El re-empuje del scheduler (DELETE + INSERT) refresca created_at, así que un
|
||||
// registro viejo que el sistema legado reenvía vuelve a entrar en la ventana.
|
||||
$pdo->exec("DELETE FROM tmp_rips WHERE numero_documento = '2222'");
|
||||
$ins->execute(['2222', json_encode([['cod_examen' => 'RE']]), 2000, '08:00:00',
|
||||
date('Y-m-d H:i:s'), null]);
|
||||
ok('un re-empuje del scheduler vuelve a hacer visible el registro', $buscar('2222') !== null);
|
||||
|
||||
echo "\n" . ($fallos === 0 ? "Todo correcto.\n" : "$fallos pruebas fallaron.\n");
|
||||
exit($fallos === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* scripts/test_tv_voz.js — node scripts/test_tv_voz.js
|
||||
*
|
||||
* Pruebas de la voz del televisor CONTRA EL CÓDIGO REAL: se extrae el bloque
|
||||
* de anuncios de display_global.php y se ejecuta con un sintetizador simulado.
|
||||
* Cubre los tres fallos intermitentes diagnosticados:
|
||||
* 1. recolector de basura llevándose la locución a mitad de frase
|
||||
* 2. motor atascado en pausa tras un cancel() (silencio total sin error)
|
||||
* 3. voz que nunca arranca (bloqueo o voz rota) dejando el cartel pegado
|
||||
*/
|
||||
const fs = require('fs');
|
||||
|
||||
let fallos = 0;
|
||||
function ok(desc, cond) {
|
||||
console.log((cond ? ' ok ' : ' FALLA ') + desc);
|
||||
if (!cond) fallos++;
|
||||
}
|
||||
|
||||
// ── Extraer el código real ──────────────────────────────────────────────
|
||||
const src = fs.readFileSync(__dirname + '/../modules/turnero/views/display_global.php', 'utf8')
|
||||
.replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"');
|
||||
const js = src.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1];
|
||||
const ini = js.indexOf('const VOCES_PREFERIDAS');
|
||||
const fin = js.indexOf('/* ── Fullscreen ── */');
|
||||
const cuerpo = 'let sonidoActivo=true,_vozES=null;function playBeep(){}'
|
||||
+ js.slice(ini, fin);
|
||||
|
||||
ok('el código extraído contiene el anclaje anti-recolector', cuerpo.includes('_uttAncladas'));
|
||||
ok('resume() se llama antes de cada speak()',
|
||||
/resume\(\);\s*\n\s*window\.speechSynthesis\.speak\(utt\)/.test(cuerpo));
|
||||
|
||||
ok('el despertador SSE está conectado (los llamados despiertan a la pestaña oculta)',
|
||||
js.includes("sse_turno.php?area=recepcion") && js.includes("addEventListener('cola_update'"));
|
||||
|
||||
// ── Armazón de simulación ───────────────────────────────────────────────
|
||||
function escenario({ bloqueado = false, msArranque = 20, caida = false, atascoUnaVez = false } = {}) {
|
||||
let t = 0; const cola = [];
|
||||
const st = (fn, ms) => cola.push({ t: t + ms, fn });
|
||||
class Utt {
|
||||
constructor(x) { this.texto = x; this.l = {}; }
|
||||
addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); }
|
||||
emit(e, arg) { (this.l[e] || []).forEach(f => f(arg || {})); }
|
||||
}
|
||||
let resumeAntesDeSpeak = false, resumePendiente = false;
|
||||
let activa = null, totalSpeaks = 0;
|
||||
const synth = {
|
||||
speaking: false, pending: false,
|
||||
pause() {},
|
||||
// Como Chrome de verdad: cancelar una locución activa le dispara
|
||||
// 'error' con interrupted. Fue exactamente el comportamiento que el
|
||||
// simulador viejo no imitaba, y por eso no cazó el doble anuncio.
|
||||
cancel() {
|
||||
synth.speaking = synth.pending = false;
|
||||
const u = activa; activa = null;
|
||||
if (u) u.emit('error', { error: 'interrupted' });
|
||||
},
|
||||
resume() { resumePendiente = true; },
|
||||
getVoices: () => [{ name: 'Sabina', lang: 'es-MX', localService: true }],
|
||||
speak(u) {
|
||||
resumeAntesDeSpeak = resumePendiente; resumePendiente = false;
|
||||
totalSpeaks++;
|
||||
if (bloqueado || caida) return; // descartado en silencio: ni pending queda
|
||||
if (atascoUnaVez && totalSpeaks === 1) {
|
||||
// El modo verificado en vivo (23/08): el motor JURA hablar
|
||||
// (speaking=true) pero no suena y 'start' no llega jamás.
|
||||
activa = u; synth.speaking = true;
|
||||
return;
|
||||
}
|
||||
activa = u;
|
||||
synth.pending = true;
|
||||
st(() => { if (activa !== u) return; synth.pending = false; synth.speaking = true; u.emit('start'); }, msArranque);
|
||||
st(() => { if (activa !== u) return; synth.speaking = false; activa = null; u.emit('end'); }, msArranque + 4000);
|
||||
},
|
||||
};
|
||||
const win = { speechSynthesis: synth };
|
||||
const beacons = [];
|
||||
const sandbox = new Function(
|
||||
'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
|
||||
'localStorage', 'navigator', 'Blob', 'Date', 'document',
|
||||
cuerpo + '; return { anunciarTurno, _uttAncladas };'
|
||||
);
|
||||
const fakeDate = { now: () => t };
|
||||
fakeDate.prototype = Date.prototype;
|
||||
const api = sandbox(
|
||||
win, st, () => {}, Utt,
|
||||
{ getItem: () => '[]', setItem: () => {} },
|
||||
{ sendBeacon: (u, b) => beacons.push(u) },
|
||||
function Blob() {},
|
||||
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }),
|
||||
{ addEventListener: () => {}, hidden: false }
|
||||
);
|
||||
const correr = (hastaMs) => {
|
||||
let n = 0;
|
||||
while (cola.length && n++ < 500) {
|
||||
cola.sort((a, b) => a.t - b.t);
|
||||
const e = cola.shift();
|
||||
if (hastaMs !== undefined && e.t > hastaMs) { cola.unshift(e); break; }
|
||||
t = e.t; e.fn();
|
||||
}
|
||||
};
|
||||
return { api, correr, beacons, resumeUsado: () => resumeAntesDeSpeak, t: () => t,
|
||||
speaks: () => totalSpeaks, hablando: () => activa };
|
||||
}
|
||||
|
||||
// ── 1. Anclaje: la locución queda referenciada mientras habla ───────────
|
||||
{
|
||||
const e = escenario();
|
||||
e.api.anunciarTurno('A01', 'Consultorio 2', null, () => {});
|
||||
e.correr(800); // ya se llamó a speak()
|
||||
ok('durante el habla la locución está anclada (no la puede recoger el GC)',
|
||||
e.api._uttAncladas.size === 1);
|
||||
e.correr(); // hasta el final
|
||||
ok('al terminar se suelta el anclaje (sin fuga de memoria)',
|
||||
e.api._uttAncladas.size === 0);
|
||||
}
|
||||
|
||||
// ── 2. resume() antes de speak: motor atascado en pausa ─────────────────
|
||||
{
|
||||
const e = escenario();
|
||||
e.api.anunciarTurno('A02', 'Consultorio 1', null, () => {});
|
||||
e.correr();
|
||||
ok('speak() siempre va precedido de resume() (destranca el motor pausado)',
|
||||
e.resumeUsado());
|
||||
}
|
||||
|
||||
// ── 3. Audio bloqueado: avisa para que el cartel no quede pegado ────────
|
||||
{
|
||||
const e = escenario({ bloqueado: true });
|
||||
let aviso = null;
|
||||
e.api.anunciarTurno('A03', 'Consultorio 3', null, () => { aviso = e.t(); });
|
||||
e.correr();
|
||||
ok('con el audio bloqueado igual avisa y el cartel se puede cerrar',
|
||||
aviso !== null && aviso < 10000);
|
||||
ok('el anclaje también se suelta cuando no llegó a hablar',
|
||||
e.api._uttAncladas.size === 0);
|
||||
}
|
||||
|
||||
// ── 4. Voz lenta (remota): no se corta ──────────────────────────────────
|
||||
{
|
||||
const e = escenario({ msArranque: 2800 });
|
||||
let termino = false;
|
||||
e.api.anunciarTurno('A04', 'Consultorio 4', 'maría gómez', () => { termino = true; });
|
||||
e.correr();
|
||||
ok('una voz que tarda 2,8 s en arrancar habla completa y termina', termino);
|
||||
}
|
||||
|
||||
// ── 5. La bitácora registra start y end ─────────────────────────────────
|
||||
{
|
||||
const e = escenario();
|
||||
e.api.anunciarTurno('A05', 'Consultorio 5', null, () => {});
|
||||
e.correr();
|
||||
ok('la bitácora mandó al menos 2 eventos (start y end) al servidor',
|
||||
e.beacons.length >= 2);
|
||||
}
|
||||
|
||||
// ── 5b. La voz no arranca: reintenta UNA vez, sin doble anuncio ─────────
|
||||
// El defecto que esto vigila: cancel() dispara 'error' en la locución vieja,
|
||||
// y sin el token de generación ese handler lanzaba OTRO reintento: dos voces
|
||||
// superpuestas diciendo lo mismo, de forma intermitente.
|
||||
{
|
||||
// Voz que queda pendiente pero jamás arranca (pending eterno no: el sim
|
||||
// bloqueado ni encola — usamos arranque infinito quitando la cola de start)
|
||||
// Modo real de Chrome: el speak se descarta EN SILENCIO (ni speaking ni
|
||||
// pending). Ese es el caso que el vigilante reintenta. El otro modo —voz
|
||||
// eternamente en pending— se tolera a propósito: es indistinguible de una
|
||||
// voz remota lenta, y cortarla fue justo el defecto del entrecortado.
|
||||
const e = escenario({ caida: true });
|
||||
let avisos = 0;
|
||||
e.api.anunciarTurno('C01', 'Consultorio 1', null, () => { avisos++; });
|
||||
e.correr(30000);
|
||||
ok('voz descartada en silencio: exactamente 2 speak (original + 1 reintento), no más',
|
||||
e.speaks() === 2);
|
||||
ok('y el cartel recibe exactamente UN aviso de cierre (vía «mudo»)', avisos === 1);
|
||||
}
|
||||
|
||||
// ── 5c. Un anuncio nuevo que cancela al anterior no lo resucita ─────────
|
||||
{
|
||||
const e = escenario({ msArranque: 20 });
|
||||
let avisosA = 0;
|
||||
e.api.anunciarTurno('D01', 'Consultorio 1', null, () => { avisosA++; });
|
||||
e.correr(1000); // D01 está hablando
|
||||
const speaksAntes = e.speaks();
|
||||
e.api.anunciarTurno('D02', 'Consultorio 2', null, () => {});
|
||||
e.correr();
|
||||
ok('cancelar al anterior con un anuncio nuevo no dispara reintentos fantasma del viejo',
|
||||
e.speaks() === speaksAntes + 1);
|
||||
}
|
||||
|
||||
// ── 5d. MOTOR ATASCADO: speaking sin 'start' → romperlo y rehablar ──────
|
||||
// Reproducido en vivo el 23/08: primer llamado tras el ocio, el motor queda
|
||||
// en speaking=true sin audio. El vigilante debe detectarlo (una locución que
|
||||
// de verdad suena dispara 'start' de inmediato), romperlo y rehablar — una
|
||||
// sola vez, sin dobles.
|
||||
{
|
||||
const e = escenario({ atascoUnaVez: true });
|
||||
let avisos = 0;
|
||||
e.api.anunciarTurno('F01', 'Consultorio 1', null, () => { avisos++; });
|
||||
e.correr();
|
||||
ok('el atasco se detecta y el reintento habla: exactamente 2 speak',
|
||||
e.speaks() === 2);
|
||||
ok('el llamado termina avisando UNA vez (el reintento habló completo)',
|
||||
avisos === 1);
|
||||
ok('sin locuciones ancladas al final (sin fugas tras el atasco)',
|
||||
e.api._uttAncladas.size === 0);
|
||||
}
|
||||
|
||||
// ── 5e. Keepalive mudo: el motor recibe trabajo inaudible en el ocio ────
|
||||
// pause/resume no mantiene despierto al motor (verificado en vivo); hablar a
|
||||
// volumen cero sí. Este caso vigila que el keepalive exista y sea inaudible.
|
||||
{
|
||||
const intervalos = [];
|
||||
let t = 0; const cola = [];
|
||||
const st = (fn, ms) => cola.push({ t: t + ms, fn });
|
||||
class Utt { constructor(x) { this.texto = x; this.l = {}; } addEventListener() {} }
|
||||
let hablado = null;
|
||||
const synth = { speaking: false, pending: false, pause() {}, cancel() {}, resume() {},
|
||||
getVoices: () => [], speak(u) { hablado = u; } };
|
||||
new Function('window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
|
||||
'localStorage', 'navigator', 'Blob', 'Date', 'document',
|
||||
cuerpo + ';')(
|
||||
{ speechSynthesis: synth }, st, (fn, ms) => intervalos.push({ fn, ms }), Utt,
|
||||
{ getItem: () => '[]', setItem: () => {} }, { sendBeacon: () => {} },
|
||||
function Blob() {},
|
||||
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => 0 }),
|
||||
{ addEventListener: () => {}, hidden: false });
|
||||
const ka = intervalos.find(i => i.ms === 25000);
|
||||
ok('existe el keepalive de 25 s', !!ka);
|
||||
if (ka) ka.fn();
|
||||
ok('en el ocio habla una letra a VOLUMEN CERO (inaudible, pero trabajo real)',
|
||||
hablado !== null && hablado.volume === 0);
|
||||
ok('con el motor ocupado el keepalive no interfiere', (() => {
|
||||
hablado = null; synth.speaking = true; if (ka) ka.fn(); return hablado === null;
|
||||
})());
|
||||
}
|
||||
|
||||
// ── 6. El camino feliz: habla bien ──────────────────────────────────────
|
||||
// Qué dice exactamente, y con qué voz. Un anuncio que "funciona" pero
|
||||
// pronuncia mal el nombre o elige la voz equivocada también es un fallo.
|
||||
{
|
||||
// Instrumentación directa: escenario propio con captura de la locución
|
||||
let t = 0; const cola = [];
|
||||
const st = (fn, ms) => cola.push({ t: t + ms, fn });
|
||||
class Utt {
|
||||
constructor(x) { this.texto = x; this.l = {}; }
|
||||
addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); }
|
||||
emit(e, a) { (this.l[e] || []).forEach(f => f(a || {})); }
|
||||
}
|
||||
let capturada = null;
|
||||
const synth = {
|
||||
speaking: false, pending: false, pause() {}, cancel() {}, resume() {},
|
||||
getVoices: () => [
|
||||
{ name: 'Microsoft Sabina', lang: 'es-MX', localService: true },
|
||||
{ name: 'Google español de Colombia', lang: 'es-CO', localService: false },
|
||||
],
|
||||
speak(u) {
|
||||
capturada = u;
|
||||
st(() => u.emit('start'), 10);
|
||||
st(() => u.emit('end'), 4000);
|
||||
},
|
||||
};
|
||||
const api = new Function(
|
||||
'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
|
||||
'localStorage', 'navigator', 'Blob', 'Date', 'document',
|
||||
cuerpo + '; return { anunciarTurno };'
|
||||
)({ speechSynthesis: synth }, st, () => {}, Utt,
|
||||
{ getItem: () => '[]', setItem: () => {} },
|
||||
{ sendBeacon: () => {} }, function Blob() {},
|
||||
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }),
|
||||
{ addEventListener: () => {}, hidden: false });
|
||||
|
||||
api.anunciarTurno('A01', 'Consultorio 2', 'maría fernanda gómez', () => {});
|
||||
let n = 0;
|
||||
while (cola.length && n++ < 200) { cola.sort((a, b) => a.t - b.t); const ev = cola.shift(); t = ev.t; ev.fn(); }
|
||||
|
||||
ok('el texto deletrea el código y termina con el destino',
|
||||
capturada && capturada.texto === 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2');
|
||||
ok('los nombres con tilde salen bien capitalizados (María, no MaríA)',
|
||||
capturada && capturada.texto.includes('María Fernanda Gómez'));
|
||||
ok('elige la voz LOCAL (Sabina) aunque la remota es-CO esté disponible',
|
||||
capturada && capturada.voice && capturada.voice.name === 'Microsoft Sabina');
|
||||
ok('sin nombre de paciente igual habla: solo código y destino', (() => {
|
||||
capturada = null;
|
||||
api.anunciarTurno('B02', 'Recepción 1', null, () => {});
|
||||
let m = 0;
|
||||
while (cola.length && m++ < 200) { cola.sort((a, b) => a.t - b.t); const ev = cola.shift(); t = ev.t; ev.fn(); }
|
||||
return capturada && capturada.texto === 'Turno B 0 2, pase a Recepción 1';
|
||||
})());
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
|
||||
process.exit(fallos === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
/**
|
||||
* services/GeminiService.php
|
||||
* Llamada a Gemini Flash y contabilidad del presupuesto de tokens de LIA.
|
||||
*
|
||||
* La usan el asistente del turnero (contexto operativo del día) y el de la
|
||||
* documentación (base de conocimiento filtrada por rol). Ambos comparten el
|
||||
* mismo presupuesto, guardado en lab_config.lia_tokens_usados.
|
||||
*/
|
||||
|
||||
final class GeminiService
|
||||
{
|
||||
public const TOKENS_MAX = 1_000_000;
|
||||
private const MODELO = 'gemini-3.5-flash';
|
||||
private const ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/';
|
||||
|
||||
private PDO $pdo;
|
||||
private string $apiKey;
|
||||
private int $tokensUsados;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$cfg = $pdo->query(
|
||||
"SELECT clave, valor FROM lab_config WHERE clave IN ('gemini_api_key','lia_tokens_usados')"
|
||||
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
$this->apiKey = trim($cfg['gemini_api_key'] ?? '');
|
||||
$this->tokensUsados = (int)($cfg['lia_tokens_usados'] ?? 0);
|
||||
}
|
||||
|
||||
public function hayClave(): bool { return $this->apiKey !== ''; }
|
||||
public function presupuestoAgotado(): bool { return $this->tokensUsados >= self::TOKENS_MAX; }
|
||||
public function tokensUsados(): int { return $this->tokensUsados; }
|
||||
public function tokensRestantes(): int { return max(0, self::TOKENS_MAX - $this->tokensUsados); }
|
||||
|
||||
/**
|
||||
* Envía una consulta y devuelve la respuesta ya contabilizada.
|
||||
*
|
||||
* @param array $historial Turnos previos: [['role'=>'user'|'model','parts'=>[['text'=>...]]], ...]
|
||||
* @return array{respuesta:string, truncada:bool, tokens_usados:int, tokens_restantes:int, tokens_max:int}
|
||||
* @throws RuntimeException si la API falla; el mensaje es apto para mostrar
|
||||
*/
|
||||
public function preguntar(string $systemPrompt, string $pregunta, array $historial = [], int $maxTokens = 2048): array
|
||||
{
|
||||
$payload = json_encode([
|
||||
'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
|
||||
'contents' => array_merge($historial, [
|
||||
['role' => 'user', 'parts' => [['text' => $pregunta]]],
|
||||
]),
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.3,
|
||||
'maxOutputTokens' => $maxTokens,
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init(self::ENDPOINT . self::MODELO . ':generateContent?key=' . $this->apiKey);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 25,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (!$raw) throw new RuntimeException('Sin respuesta del servicio de IA');
|
||||
|
||||
$json = json_decode($raw, true);
|
||||
if ($code !== 200 || empty($json['candidates'][0]['content']['parts'][0]['text'])) {
|
||||
throw new RuntimeException('Error IA: ' . ($json['error']['message'] ?? 'Error desconocido'));
|
||||
}
|
||||
|
||||
$respuesta = $json['candidates'][0]['content']['parts'][0]['text'];
|
||||
|
||||
// Gemini avisa con finishReason cuando corta; sin esto el usuario recibe
|
||||
// un texto truncado a media frase sin saber que faltó contenido.
|
||||
$truncada = (($json['candidates'][0]['finishReason'] ?? null) === 'MAX_TOKENS');
|
||||
if ($truncada) {
|
||||
$respuesta .= "\n\n_(respuesta cortada por longitud — pide el detalle por partes)_";
|
||||
}
|
||||
|
||||
$this->contabilizar(
|
||||
(int)($json['usageMetadata']['totalTokenCount']
|
||||
?? (mb_strlen($pregunta) + mb_strlen($respuesta)) / 4)
|
||||
);
|
||||
|
||||
return [
|
||||
'respuesta' => $respuesta,
|
||||
'truncada' => $truncada,
|
||||
'tokens_usados' => $this->tokensUsados,
|
||||
'tokens_restantes' => $this->tokensRestantes(),
|
||||
'tokens_max' => self::TOKENS_MAX,
|
||||
];
|
||||
}
|
||||
|
||||
private function contabilizar(int $tokens): void
|
||||
{
|
||||
$this->tokensUsados += $tokens;
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO lab_config (clave, valor) VALUES ('lia_tokens_usados', ?)
|
||||
ON DUPLICATE KEY UPDATE valor = ?"
|
||||
)->execute([$this->tokensUsados, $this->tokensUsados]);
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,32 @@ class WhatsAppService
|
||||
/**
|
||||
* Enviar mensaje con botones interactivos
|
||||
*/
|
||||
/**
|
||||
* Pedirle a la persona que comparta su número de teléfono.
|
||||
*
|
||||
* Quien activó el nombre de usuario de WhatsApp llega sin teléfono, y sin él
|
||||
* no se le puede cruzar con su historia ni con el turnero. Este botón es la
|
||||
* vía que da Meta para pedírselo: la persona decide si lo comparte, y si
|
||||
* acepta llega un mensaje de tipo `contacts` con el número.
|
||||
*
|
||||
* Solo tiene sentido dentro de la ventana de 24 horas; fuera de ella hay que
|
||||
* usar una plantilla con el botón REQUEST_CONTACT_INFO.
|
||||
*/
|
||||
public function pedirContacto($to, $bodyText)
|
||||
{
|
||||
return $this->sendMessage([
|
||||
'messaging_product' => 'whatsapp',
|
||||
'recipient_type' => 'individual',
|
||||
'to' => $this->formatPhoneNumber($to),
|
||||
'type' => 'interactive',
|
||||
'interactive' => [
|
||||
'type' => 'request_contact_info',
|
||||
'body' => ['text' => $bodyText],
|
||||
'action' => ['name' => 'request_contact_info'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendInteractiveMessage($to, $bodyText, $buttons, $header = null, $footer = null)
|
||||
{
|
||||
$interactive = [
|
||||
@@ -767,6 +793,14 @@ class WhatsAppService
|
||||
unset($payload['__app_meta']);
|
||||
}
|
||||
|
||||
// A quien oculta su teléfono se le responde por BSUID, y Meta exige que vaya
|
||||
// en `recipient` en lugar de `to`. Se corrige aquí, que es por donde pasan
|
||||
// todos los envíos, en vez de en cada método que arma un payload.
|
||||
if (isset($payload['to']) && self::esBsuid($payload['to'])) {
|
||||
$payload['recipient'] = $payload['to'];
|
||||
unset($payload['to']);
|
||||
}
|
||||
|
||||
// Debug log (payload sent to WhatsApp)
|
||||
error_log("WhatsApp API URL: " . $url);
|
||||
error_log("WhatsApp Token length: " . strlen($this->token));
|
||||
@@ -881,11 +915,27 @@ class WhatsAppService
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* ¿El destinatario es un BSUID (identificador de usuario por empresa) y no un teléfono?
|
||||
* La definición vive en config.php, que es donde la ven también las clases del
|
||||
* laboratorio; aquí solo se expone para quien ya tiene el servicio a la mano.
|
||||
*/
|
||||
public static function esBsuid($destinatario)
|
||||
{
|
||||
return esBsuid($destinatario);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatear número de teléfono
|
||||
*/
|
||||
private function formatPhoneNumber($phone)
|
||||
{
|
||||
// Un BSUID no es un teléfono: se devuelve intacto, porque quitarle
|
||||
// el punto y las letras lo convertiría en un destinatario inexistente.
|
||||
if (self::esBsuid($phone)) {
|
||||
return $phone;
|
||||
}
|
||||
|
||||
// Remover caracteres especiales
|
||||
$phone = preg_replace('/[^0-9]/', '', $phone);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ if ($modoTurnero) {
|
||||
p.id AS paciente_id,
|
||||
p.nombre_completo AS paciente_nombre,
|
||||
p.numero_documento, p.tipo_documento,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, p.direccion, p.genero,
|
||||
t.sesion_id,
|
||||
ts.numero_orden,
|
||||
med.nombres AS medico_nombres, med.apellidos AS medico_apellidos,
|
||||
@@ -155,6 +155,8 @@ if ($modoTurnero) {
|
||||
'fecha_nacimiento' => $tcRow['fecha_nacimiento'] ?? '',
|
||||
'telefono' => $tcRow['paciente_telefono'] ?? '',
|
||||
'eps' => $tcRow['eps'] ?? '',
|
||||
'direccion' => $tcRow['direccion'] ?? '',
|
||||
'genero' => $tcRow['genero'] ?? '',
|
||||
'medico_nombre' => trim(($tcRow['medico_nombres'] ?? '') . ' ' . ($tcRow['medico_apellidos'] ?? '')),
|
||||
'medico_especialidad'=> $tcRow['medico_especialidad'] ?? '',
|
||||
'medico_codigo' => $tcRow['medico_codigo'] ?? '',
|
||||
@@ -177,7 +179,7 @@ if ($modoTurnero) {
|
||||
f.esquema, f.permite_firma, f.requiere_firma,
|
||||
f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina,
|
||||
p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, p.direccion, p.genero,
|
||||
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email,
|
||||
ld.numero_orden
|
||||
FROM lab_form_envios e
|
||||
@@ -201,7 +203,7 @@ if ($modoTurnero) {
|
||||
f.esquema, f.permite_firma, f.requiere_firma,
|
||||
f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina,
|
||||
p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, p.direccion, p.genero,
|
||||
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email,
|
||||
ld.numero_orden
|
||||
FROM lab_form_envios e
|
||||
@@ -694,6 +696,14 @@ function _addExamWizardHtml(string $cid): string {
|
||||
.campo-linked { display: flex; gap: 16px; padding: 6px 0;
|
||||
border-bottom: 1px solid #f0f0f0; }
|
||||
.campo-linked-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; }
|
||||
/* Antes era solo un lápiz gris de 11px: estaba, pero nadie lo encontraba.
|
||||
Lleva texto y contorno para que se lea como lo que es, un botón. */
|
||||
.lnk-corregir { background:none; border:none; border-radius:4px;
|
||||
color:#1565c0; cursor:pointer; padding:1px 5px; font-size:10px;
|
||||
font-weight:600; white-space:nowrap; flex-shrink:0;
|
||||
display:inline-flex; align-items:center; gap:3px;
|
||||
text-decoration:underline; }
|
||||
.lnk-corregir:hover { background:#1565c0; border-color:#1565c0; color:#fff; }
|
||||
.campo-linked-valor { flex: 1; font-size: 13px; font-weight: 600;
|
||||
color: #1565c0; }
|
||||
|
||||
@@ -997,7 +1007,20 @@ function _addExamWizardHtml(string $cid): string {
|
||||
<div id="cargarAnteriorMsg" class="small text-muted mb-2" style="display:none"></div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$paciente = $datosPrefilled['__paciente'] ?? [];
|
||||
// Los campos vinculados leen de aquí. En los envíos de formulario,
|
||||
// datos_prefilled es una copia tomada al enviarlo, que puede no incluir
|
||||
// claves agregadas después; se completa con los datos vivos del paciente
|
||||
// para que un campo automático no quede vacío por eso.
|
||||
$paciente = ($datosPrefilled['__paciente'] ?? []) + array_filter([
|
||||
'nombre_completo' => $envio['paciente_nombre'] ?? null,
|
||||
'numero_documento' => $envio['numero_documento'] ?? null,
|
||||
'tipo_documento' => $envio['tipo_documento'] ?? null,
|
||||
'fecha_nacimiento' => $envio['fecha_nacimiento'] ?? null,
|
||||
'telefono' => $envio['paciente_telefono'] ?? null,
|
||||
'eps' => $envio['eps'] ?? null,
|
||||
'direccion' => $envio['direccion'] ?? null,
|
||||
'genero' => $envio['genero'] ?? null,
|
||||
], fn($v) => $v !== null && $v !== '');
|
||||
|
||||
// Firma global del profesional (un solo valor para todos los campos firma_profesional)
|
||||
$firmaSharedProfesional = $envio['firma_profesional_svg'] ?? null;
|
||||
@@ -1008,6 +1031,7 @@ function _addExamWizardHtml(string $cid): string {
|
||||
$_hayDatosPerCampoSvg = !empty(array_filter(array_keys($datosCliente ?? []), fn($k) => str_ends_with($k, '_svg')));
|
||||
|
||||
// Rastrear si ya se renderizó la firma inline (para no duplicarla al pie)
|
||||
$_lnkBtnPuesto = false; // el lápiz de corregir va una sola vez
|
||||
$_renderedFirmaProfesional = false;
|
||||
$_renderedFirmaPaciente = false;
|
||||
$_esquemaTieneFirmaPaciente = false;
|
||||
@@ -1048,6 +1072,18 @@ function _addExamWizardHtml(string $cid): string {
|
||||
$cond = $campo['condicion'] ?? null;
|
||||
if ($cond) {
|
||||
$vCtrl = $todos[$cond['campo_id']] ?? null;
|
||||
// La condición puede depender de un campo vinculado (p. ej. el
|
||||
// sexo del paciente, que gobierna la sección obstétrica). Esos
|
||||
// no están en las respuestas: su valor sale de la ficha.
|
||||
if ($vCtrl === null) {
|
||||
foreach ($esquema as $__c) {
|
||||
if (($__c['id'] ?? '') === $cond['campo_id']
|
||||
&& ($__c['tipo'] ?? '') === 'linked') {
|
||||
$vCtrl = $paciente[$__c['linked_key'] ?? ''] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$vCond = $cond['valores'] ?? ($cond['valor'] ? [$cond['valor']] : []);
|
||||
$_saltarSeccion = is_array($vCtrl)
|
||||
? empty(array_intersect($vCond, $vCtrl))
|
||||
@@ -1256,6 +1292,12 @@ function _addExamWizardHtml(string $cid): string {
|
||||
<div class="campo-linked" data-campo-id="<?= esc2($cid) ?>">
|
||||
<div class="campo-linked-label"><?= $label ?></div>
|
||||
<div class="campo-linked-valor"><?= esc2($lval) ?></div>
|
||||
<?php if ($modoTurnero && $embebido && $modoEditar && !$_lnkBtnPuesto): $_lnkBtnPuesto = true; ?>
|
||||
<button type="button" class="lnk-corregir no-print" onclick="pedirCorregirPaciente()"
|
||||
title="Abrir la ficha del paciente para corregir sus datos">
|
||||
<i class="fas fa-pen" style="font-size:9px"></i>Corregir
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; continue; endif; // linked
|
||||
if ($tipo === 'textarea'):
|
||||
@@ -2230,6 +2272,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}, 350);
|
||||
});
|
||||
<?php endif; ?>
|
||||
/* El formulario va embebido en la estación: no puede abrir el modal de
|
||||
corrección por su cuenta, le pide al contenedor que lo haga. */
|
||||
function pedirCorregirPaciente() {
|
||||
try { window.parent.postMessage({ type: 'corregirPaciente' }, '*'); } catch (e) {}
|
||||
}
|
||||
|
||||
window._fpwPreFirma = <?= json_encode($firmaProfPreguardada) ?>;
|
||||
window._profNombre = <?= json_encode($profNombre) ?>;
|
||||
window._profDocumento = <?= json_encode($profDocumento) ?>;
|
||||
@@ -2259,6 +2307,8 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
|
||||
return;
|
||||
}
|
||||
var datos = json.datos_respuestas || {};
|
||||
var tocados = [], llenos = 0;
|
||||
|
||||
document.querySelectorAll('[name]').forEach(function(el) {
|
||||
var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0, -2) : raw;
|
||||
if (!(name in datos)) return;
|
||||
@@ -2271,9 +2321,25 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
|
||||
} else {
|
||||
el.value = val;
|
||||
}
|
||||
tocados.push(el);
|
||||
llenos++;
|
||||
});
|
||||
|
||||
// Asignar el valor a mano no dispara ningún evento, y las secciones
|
||||
// condicionales del formulario —tipo de dolor, tipo de cáncer, grupo
|
||||
// sanguíneo— se despliegan escuchando 'change' e 'input'. Sin esto los
|
||||
// datos entraban pero las secciones seguían ocultas, así que parecía
|
||||
// que el botón no hacía nada.
|
||||
tocados.forEach(function(el) {
|
||||
['input', 'change'].forEach(function(tipo) {
|
||||
el.dispatchEvent(new Event(tipo, { bubbles: true }));
|
||||
});
|
||||
});
|
||||
|
||||
msg.style.display = '';
|
||||
msg.textContent = 'Datos cargados de la visita ' + (json.turno_codigo || '') + '. Revise y firme.';
|
||||
msg.textContent = llenos
|
||||
? 'Se cargaron ' + llenos + ' dato(s) de la visita ' + (json.turno_codigo || '') + '. Revise y firme.'
|
||||
: 'La visita ' + (json.turno_codigo || '') + ' no tiene datos que correspondan a este formulario.';
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false;
|
||||
@@ -3193,13 +3259,25 @@ const topaz = (() => {
|
||||
} else {
|
||||
var radio = ctrl.querySelector('input[type="radio"]:checked');
|
||||
var sel = ctrl.querySelector('select');
|
||||
var v = radio ? radio.value : (sel ? sel.value : '');
|
||||
var txt = ctrl.querySelector('input[type="text"], textarea');
|
||||
// Un campo vinculado se muestra como texto, no como control:
|
||||
// es el caso del sexo, que gobierna la sección obstétrica.
|
||||
var lnk = ctrl.classList.contains('campo-linked')
|
||||
? ctrl.querySelector('.campo-linked-valor') : null;
|
||||
var v = radio ? radio.value
|
||||
: sel ? sel.value
|
||||
: lnk ? lnk.textContent.trim()
|
||||
: txt ? txt.value : '';
|
||||
activo = valoresCond.includes(v);
|
||||
}
|
||||
var ids = [sec.sepId].concat(sec.elemIds);
|
||||
ids.forEach(function(id) {
|
||||
var el = document.querySelector('[data-campo-id="' + id + '"]');
|
||||
if (!el) return;
|
||||
// El PHP marca con display:none los campos de secciones cuya
|
||||
// condición no se cumple. Esta función anima con max-height y
|
||||
// opacity, así que sin quitar el display el campo nunca aparece.
|
||||
if (activo) el.style.removeProperty('display');
|
||||
el.style.transition = 'opacity .2s, max-height .3s';
|
||||
el.style.maxHeight = activo ? '2000px' : '0';
|
||||
el.style.opacity = activo ? '1' : '0';
|
||||
|
||||
Reference in New Issue
Block a user