feat: 6 nuevas funcionalidades - notas obligatorias turno, reporte enfermero, plantilla WhatsApp, adjuntar orden modal, roles solo lectura, fix valores copago

This commit is contained in:
Lizandro Guarnizo
2026-04-02 09:41:49 -05:00
parent 052a263659
commit d8ceb3f822
14 changed files with 2794 additions and 49 deletions
File diff suppressed because it is too large Load Diff
+522
View File
@@ -0,0 +1,522 @@
# Documentación del Sistema de Laboratorio
> **Sistema**: Laboratorio Clínico — Módulos de Agendamiento y Formularios
> **Última actualización**: 27/03/2026
---
## Tabla de contenidos
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.*
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* API — Bloquear / Desbloquear usuario de WhatsApp
* POST { user_id: int, blocked: bool }
* Cambia users.status entre 'active' y 'blocked'.
* El BotService omite cualquier mensaje entrante de usuarios bloqueados.
*/
require_once '../config/config.php';
header('Content-Type: application/json; charset=utf-8');
requireAuthentication();
try {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$userId = isset($input['user_id']) ? intval($input['user_id']) : 0;
if (!isset($input['blocked'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'user_id and blocked are required']);
exit;
}
$blocked = (bool)$input['blocked'];
if ($userId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid user_id']);
exit;
}
$db = Database::getInstance();
$status = $blocked ? 'blocked' : 'active';
$db->update('users', ['status' => $status], 'id = :id', ['id' => $userId]);
echo json_encode(['success' => true, 'blocked' => $blocked, 'status' => $status]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
+3 -1
View File
@@ -26,6 +26,7 @@ try {
u.id as user_id,
u.phone_number,
COALESCE(u.name, u.phone_number) as name,
u.status as user_status,
c.content as last_message,
c.direction as last_direction,
c.message_type as last_message_type,
@@ -43,7 +44,7 @@ try {
FROM conversations
GROUP BY user_id
)
GROUP BY u.id, u.phone_number, u.name, c.content, c.direction, c.message_type, c.created_at, c.status, u.advisor_requested, u.terms_pending, u.terms_accepted_at
GROUP BY u.id, u.phone_number, u.name, u.status, c.content, c.direction, c.message_type, c.created_at, c.status, u.advisor_requested, u.terms_pending, u.terms_accepted_at
ORDER BY c.created_at DESC
LIMIT 50"
);
@@ -59,6 +60,7 @@ try {
'user_id' => intval($conv['user_id']),
'phone_number' => $conv['phone_number'],
'name' => $conv['name'],
'user_status' => $conv['user_status'] ?? 'active',
'last_message' => $conv['last_message'] ?? '',
'last_direction' => $conv['last_direction'] ?? 'incoming',
'last_message_type' => $conv['last_message_type'] ?? 'text',
+84
View File
@@ -0,0 +1,84 @@
<?php
/**
* API: Reporte de domicilios agrupado por enfermero, año y mes.
*
* GET params (todos opcionales):
* anio Año a filtrar (ej. 2026). Si se omite, devuelve todos.
* mes Mes 1-12. Sólo funciona junto con anio.
* enfermera_id ID de la enfermera. Si se omite, devuelve todas.
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../classes/Database.php';
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
requireAuthentication();
$db = Database::getInstance();
$anio = isset($_GET['anio']) && ctype_digit($_GET['anio']) ? (int)$_GET['anio'] : null;
$mes = isset($_GET['mes']) && ctype_digit($_GET['mes']) ? (int)$_GET['mes'] : null;
$enfermeraId = isset($_GET['enfermera_id'])&& ctype_digit($_GET['enfermera_id']) ? (int)$_GET['enfermera_id']: null;
$wheres = [];
$params = [];
if ($anio !== null) {
$wheres[] = 'YEAR(d.fecha_programada) = ?';
$params[] = $anio;
if ($mes !== null) {
$wheres[] = 'MONTH(d.fecha_programada) = ?';
$params[] = $mes;
}
}
if ($enfermeraId !== null) {
$wheres[] = 'e.id = ?';
$params[] = $enfermeraId;
}
$where = $wheres ? 'WHERE ' . implode(' AND ', $wheres) : '';
$filas = $db->fetchAll(
"SELECT
e.id AS enfermera_id,
e.nombre_completo AS enfermera,
YEAR(d.fecha_programada) AS anio,
MONTH(d.fecha_programada) AS mes,
COUNT(*) AS total,
SUM(d.estado = 'completado') AS completados,
SUM(d.estado = 'cancelado') AS cancelados,
SUM(d.estado NOT IN ('completado','cancelado')) AS en_proceso
FROM lab_domicilios d
JOIN lab_asignaciones a ON a.domicilio_id = d.id
AND a.estado NOT IN ('liberada')
JOIN lab_enfermeras e ON e.id = a.enfermera_id
$where
GROUP BY e.id, YEAR(d.fecha_programada), MONTH(d.fecha_programada)
ORDER BY e.nombre_completo, YEAR(d.fecha_programada) DESC, MONTH(d.fecha_programada) DESC",
$params
);
$meses_es = [
1=>'Ene',2=>'Feb',3=>'Mar',4=>'Abr',5=>'May',6=>'Jun',
7=>'Jul',8=>'Ago',9=>'Sep',10=>'Oct',11=>'Nov',12=>'Dic',
];
foreach ($filas as &$f) {
$t = (int)$f['total'];
$f['tasa_pct'] = $t ? round((int)$f['completados'] / $t * 100) : 0;
$f['mes_nombre'] = $meses_es[(int)$f['mes']] ?? $f['mes'];
}
unset($f);
// Lista de enfermeras para el selector del filtro
$enfermeras = $db->fetchAll(
"SELECT id, nombre_completo FROM lab_enfermeras WHERE activa = 1 ORDER BY nombre_completo"
);
echo json_encode([
'success' => true,
'filas' => $filas,
'enfermeras' => $enfermeras,
], JSON_UNESCAPED_UNICODE);
+28 -3
View File
@@ -485,13 +485,20 @@ function authenticateUser($username, $password) {
$updateLogin = $pdo->prepare("UPDATE admin_users SET last_login = NOW() WHERE id = ?");
$updateLogin->execute([$admin['id']]);
// Cargar módulos del rol
// Cargar módulos del rol (slug y permiso)
$modules = [];
$modulePermissions = [];
$roleSlug = $admin['role'] ?? 'admin';
if (!empty($admin['role_id'])) {
$modStmt = $pdo->prepare("SELECT module_slug FROM role_modules WHERE role_id = ?");
$modStmt = $pdo->prepare(
"SELECT module_slug, COALESCE(permission,'write') AS permission
FROM role_modules WHERE role_id = ?"
);
$modStmt->execute([$admin['role_id']]);
$modules = array_column($modStmt->fetchAll(PDO::FETCH_ASSOC), 'module_slug');
foreach ($modStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$modules[] = $row['module_slug'];
$modulePermissions[$row['module_slug']] = $row['permission'];
}
} elseif ($roleSlug === 'admin') {
// Fallback: admin sin role_id tiene todos los módulos
$modules = array_keys(SYSTEM_MODULES);
@@ -508,6 +515,7 @@ function authenticateUser($username, $password) {
'role_id' => $admin['role_id'] ?? null,
'enfermera_id' => $admin['enfermera_id'] ?? null,
'modules' => $modules,
'module_permissions' => $modulePermissions,
];
}
} else {
@@ -609,6 +617,23 @@ function hasModule(string $slug): bool {
return in_array($slug, $modules, true);
}
/**
* Comprueba si el usuario tiene permiso de escritura (write) en el módulo.
* Los administradores tienen acceso de escritura total.
* Un usuario con permission='read' solo puede ver, no modificar.
*/
function hasModuleWrite(string $slug): bool {
// Admins siempre pueden escribir
if (userHasRole('admin')) return true;
$permissions = $_SESSION['admin_user']['module_permissions'] ?? null;
if ($permissions === null) {
// Sesión legacy sin 'module_permissions': si tiene el módulo, se asume write
return hasModule($slug);
}
return ($permissions[$slug] ?? '') === 'write';
}
/**
* Detiene la ejecución si el usuario no tiene el rol requerido.
*/
+86 -1
View File
@@ -594,6 +594,29 @@ if (!isUserLoggedIn()) {
.notification-toast.urgent { border-left: 4px solid #e74c3c; }
/* Badge de usuario bloqueado en el header */
#blocked-indicator {
display: none;
margin-left: 8px;
background: #dc3545;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
vertical-align: middle;
letter-spacing: .3px;
}
/* Botón bloquear — estados */
#block-user-btn.is-blocked { background: rgba(220,53,69,.18); border-color: #dc3545; color: #dc3545; }
#block-user-btn.is-blocked:hover { background: rgba(220,53,69,.28); }
/* Ítem bloqueado en sidebar */
.conversation-item.user-blocked { opacity: .6; }
.conversation-item.user-blocked .conversation-name::after {
content: ' 🚫';
font-size: 10px;
}
/* Visual destacado para notificaciones de tipo "attention" (ej. usuario subió documentos) */
.notification-toast.attention {
background: linear-gradient(90deg, #fff7e6, #fff3e0);
@@ -912,6 +935,7 @@ if (!isUserLoggedIn()) {
<div class="chat-header-info">
<h6 id="chat-name"><span id="chat-name-text">Usuario</span> <button class="btn btn-sm btn-link" id="edit-user-btn" title="Editar usuario"><i class="fas fa-user-edit"></i></button>
<span id="hold-indicator" style="display:none; margin-left:8px; color:#b85; font-weight:600">EN ESPERA</span>
<span id="blocked-indicator"><i class="fas fa-ban me-1"></i>BLOQUEADO</span>
<span id="terms-pending-badge" title="Este usuario aún no ha aceptado los Términos y Condiciones"
style="display:none; margin-left:8px; background:#dc3545; color:#fff; font-size:10px; font-weight:600; padding:2px 7px; border-radius:10px; vertical-align:middle; cursor:default;">
<i class="fas fa-file-contract me-1"></i>T&amp;C Pendiente
@@ -948,6 +972,10 @@ if (!isUserLoggedIn()) {
<button class="btn btn-sm btn-success" id="schedule-reminder-btn" title="Programar recordatorio">
<i class="fas fa-calendar-plus"></i>
</button>
<!-- Botón bloquear/desbloquear usuario -->
<button class="btn btn-sm btn-outline-danger" id="block-user-btn" title="Bloquear usuario">
<i class="fas fa-ban"></i>
</button>
<!-- Botón actualizar mensajes -->
<button class="btn btn-sm btn-outline-light" id="refresh-messages-btn" title="Actualizar mensajes"><i class="fas fa-sync-alt"></i></button>
<button class="btn btn-sm btn-outline-secondary" id="mark-unread-btn" title="Marcar conversación como no leída" style="display:none;">Marcar no leído</button>
@@ -2762,12 +2790,13 @@ if (!isUserLoggedIn()) {
// different color if has unread and not active
const unreadClass = (conv.unread_count && conv.unread_count > 0 && !isActive) ? 'unread' : '';
const attentionClass = conv.advisor_requested ? ' attention' : '';
const blockedClass = conv.user_status === 'blocked' ? ' user-blocked' : '';
// Escapar caracteres especiales en nombre y teléfono para no romper el onclick
const safeName = (conv.name || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '&quot;').replace(/`/g, '\\`');
const safePhone = (conv.phone_number || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `
<div class="conversation-item ${isActive} ${unreadClass}${attentionClass}" data-user-id="${conv.user_id}" onclick="chat.openConversation(${conv.user_id}, '${safeName}', '${safePhone}')">
<div class="conversation-item ${isActive} ${unreadClass}${attentionClass}${blockedClass}" data-user-id="${conv.user_id}" onclick="chat.openConversation(${conv.user_id}, '${safeName}', '${safePhone}')">
<div class="conversation-avatar-wrapper">
${avatar}
</div>
@@ -3260,6 +3289,62 @@ if (!isUserLoggedIn()) {
}
};
}
// ── Botón Bloquear / Desbloquear ────────────────────────────
const blockBtn = document.getElementById('block-user-btn');
const blockedIndicator = document.getElementById('blocked-indicator');
if (blockBtn) {
const isBlocked = conv.user_status === 'blocked';
// Actualizar apariencia
blockBtn.classList.toggle('is-blocked', isBlocked);
blockBtn.title = isBlocked ? 'Desbloquear usuario' : 'Bloquear usuario';
blockBtn.innerHTML = isBlocked
? '<i class="fas fa-lock-open"></i>'
: '<i class="fas fa-ban"></i>';
if (blockedIndicator) {
blockedIndicator.style.display = isBlocked ? 'inline' : 'none';
}
blockBtn.onclick = async () => {
const currentlyBlocked = conv.user_status === 'blocked';
const action = currentlyBlocked ? 'desbloquear' : 'bloquear';
if (!confirm(`¿Confirmas ${action} a este usuario?\n${currentlyBlocked ? 'Podrá volver a interactuar con el bot.' : 'El bot ignorará sus mensajes.'}`)) return;
blockBtn.disabled = true;
try {
const resp = await fetch('api/block_user.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, blocked: !currentlyBlocked }),
cache: 'no-store'
});
if (resp.status === 401) { alert('Sesión expirada.'); return; }
const json = await resp.json();
if (json && json.success) {
conv.user_status = json.status;
const nowBlocked = json.blocked;
blockBtn.classList.toggle('is-blocked', nowBlocked);
blockBtn.title = nowBlocked ? 'Desbloquear usuario' : 'Bloquear usuario';
blockBtn.innerHTML = nowBlocked
? '<i class="fas fa-lock-open"></i>'
: '<i class="fas fa-ban"></i>';
if (blockedIndicator) {
blockedIndicator.style.display = nowBlocked ? 'inline' : 'none';
}
// Actualizar ítem en sidebar
const sideItem = document.querySelector(`.conversation-item[data-user-id="${userId}"]`);
if (sideItem) sideItem.classList.toggle('user-blocked', nowBlocked);
showAlert(nowBlocked ? '🚫 Usuario bloqueado' : '✅ Usuario desbloqueado', nowBlocked ? 'warning' : 'success');
} else {
alert('Error: ' + (json && json.error ? json.error : 'Error desconocido'));
}
} catch (e) {
console.error('Error toggling block', e);
alert('Error al cambiar estado de bloqueo');
} finally {
blockBtn.disabled = false;
}
};
}
}
// Cargar mensajes (con paginación)
+98 -7
View File
@@ -13,6 +13,7 @@ if (!isUserLoggedIn()) {
$usuario = $_SESSION['admin_user'];
$rol = $usuario['role'] ?? 'admin';
$enfId = (int)($usuario['enfermera_id'] ?? 0);
$enfermeraNombre = $usuario['full_name'] ?? $usuario['username'] ?? 'El profesional de salud';
// Si es admin puede simular ver la agenda de otra enfermera vía ?eid=X
if ($rol === 'admin' && isset($_GET['eid'])) {
@@ -388,6 +389,7 @@ $hoy = date('Y-m-d');
<script>
const ENFERMERA_ID = <?= (int)$enfId ?>;
const ROL = '<?= $rol ?>';
const ENFERMERO_NOMBRE = <?= json_encode($enfermeraNombre) ?>;
// Etiquetas descriptivas
const ESTADOS_LABEL = {
@@ -662,10 +664,18 @@ const portal = {
<i class="fas fa-plus me-1"></i>Servicio extra
</button>` : ''}
${item.paciente_telefono
? `<a class="btn btn-outline-success btn-accion"
href="https://wa.me/${item.paciente_telefono.replace(/\D/g,'')}" target="_blank">
? (()=>{
const tel = item.paciente_telefono.replace(/\D/g,'');
const txt = encodeURIComponent(
'Hola, soy ' + ENFERMERO_NOMBRE + ', el profesional de salud asignado a su atenci\u00f3n. '+
'Me comunico con ' + (item.paciente_nombre||'usted') + ' para confirmar la visita programada. \u00bfTiene alguna pregunta?'
);
return `<a class="btn btn-outline-success btn-accion"
href="https://wa.me/${tel}?text=${txt}" target="_blank"
title="Enviar mensaje WhatsApp">
<i class="fab fa-whatsapp"></i>
</a>` : ''}
</a>`;
})() : ''}
<button class="btn btn-outline-secondary btn-accion btn-form-envio"
style="color:#6f42c1;border-color:#6f42c1"
data-dom-id="${item.domicilio_id}"
@@ -686,6 +696,43 @@ const portal = {
// ── Cambiar estado ─────────────────────────────────────────────────────
async actualizarEstado(domId, nuevoEstado, notas = '') {
// ── BLOQUEO: para completar es obligatorio tener al menos una nota ──
if (nuevoEstado === 'completado') {
// Verificar en caché o cargar desde API
let cache = notasManager._cache[domId];
if (!cache) {
try {
const r = await fetch(`api/lab/get_notas_domicilio.php?domicilio_id=${domId}`);
const d = await r.json();
const notas = d.success ? (d.data || []) : [];
cache = {
clinica: notas.find(n => n.tipo === 'clinica') || null,
libres: notas.filter(n => n.tipo === 'libre')
};
notasManager._cache[domId] = cache;
} catch (e) {
cache = { clinica: null, libres: [] };
}
}
const tieneNota = cache.clinica || cache.libres.length > 0;
if (!tieneNota) {
// Abrir el sheet de notas para que registre una antes de completar
// Guardamos el intento para retomarlo al cerrar la nota
notasManager._pendingCompleteId = domId;
// Mostrar aviso visual dentro del sheet
notasManager.abrirLibre(domId, null);
// Colocar banner de advertencia en la parte superior del sheet
const inner = document.getElementById('nota-sheet-inner');
if (inner) {
const banner = document.createElement('div');
banner.id = 'nota-required-banner';
banner.style.cssText = 'background:#fff3cd;border:1px solid #ffc107;border-radius:8px;padding:10px 12px;margin-bottom:12px;font-size:.82rem;color:#856404;';
banner.innerHTML = '<i class="fas fa-exclamation-triangle me-1"></i><strong>Nota obligatoria</strong> — Debes registrar al menos una nota antes de marcar este domicilio como completado.';
inner.prepend(banner);
}
return;
}
}
try {
const r = await fetch('api/lab/update_domicilio_enfermero.php', {
method: 'POST',
@@ -1100,6 +1147,16 @@ const formVer = {
placeholder="Instrucciones adicionales para el servicio…"></textarea>
</div>
<!-- Orden médica / Archivo -->
<div class="mb-2">
<label class="form-label small fw-semibold">
Orden médica / Archivo <span class="text-muted fw-normal">(opcional)</span>
</label>
<input type="file" id="na-orden-file" accept=".pdf,.jpg,.jpeg,.png"
class="form-control form-control-sm">
<div id="na-orden-preview" class="mt-1 small text-muted"></div>
</div>
<div id="na-error" class="alert alert-danger py-2 d-none small"></div>
</div>
<div class="modal-footer py-2">
@@ -1386,6 +1443,23 @@ const agendaNueva = {
});
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error al guardar');
// Subir archivo adjunto si el usuario seleccionó uno
const archivoInput = document.getElementById('na-orden-file');
if (archivoInput?.files?.length && d.id) {
const fd = new FormData();
fd.append('file', archivoInput.files[0]);
fd.append('domicilio_id', d.id);
try {
const rFile = await fetch('api/lab/upload_orden.php', { method: 'POST', body: fd });
// Si falla el upload no bloqueamos; el domicilio ya fue creado
const dFile = await rFile.json();
if (!dFile.success) console.warn('No se pudo adjuntar el archivo:', dFile.error);
} catch (eFile) {
console.warn('Error al subir archivo:', eFile);
}
}
this._modal.hide();
portal.cargar();
} catch (e) {
@@ -1620,6 +1694,7 @@ const notasManager = {
_domId: null, // domicilio actualmente en el sheet
_notaId: null, // null = nueva nota libre, número = editar
_imagen: null, // File pendiente de subir
_pendingCompleteId: null, // domicilio_id pendiente de marcar completado tras guardar nota
// ── Abrir/cerrar sección de notas en la tarjeta ────────────────────────
async toggle(domId, btn) {
@@ -1847,9 +1922,17 @@ const notasManager = {
});
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error al guardar');
delete this._cache[this._domId];
const savedDomId = this._domId;
delete this._cache[savedDomId];
this._cerrarSheet();
await this._renderSeccion(this._domId);
await this._renderSeccion(savedDomId);
// Si había un completado pendiente para este domicilio, retomarlo
if (this._pendingCompleteId && this._pendingCompleteId == savedDomId) {
this._pendingCompleteId = null;
if (confirm('✅ Nota guardada. ¿Marcar el domicilio como completado ahora?')) {
portal.actualizarEstado(savedDomId, 'completado');
}
}
} catch(e) { alert('Error: ' + e.message); }
},
@@ -1886,9 +1969,17 @@ const notasManager = {
});
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error al guardar');
delete this._cache[this._domId];
const savedDomId = this._domId;
delete this._cache[savedDomId];
this._cerrarSheet();
await this._renderSeccion(this._domId);
await this._renderSeccion(savedDomId);
// Si había un completado pendiente para este domicilio, retomarlo
if (this._pendingCompleteId && this._pendingCompleteId == savedDomId) {
this._pendingCompleteId = null;
if (confirm('✅ Nota guardada. ¿Marcar el domicilio como completado ahora?')) {
portal.actualizarEstado(savedDomId, 'completado');
}
}
} catch(e) { alert('Error: ' + e.message); }
},
+7 -3
View File
@@ -10,6 +10,7 @@ if (!isUserLoggedIn()) {
}
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
$puedeEscribir = hasModuleWrite('lab_domicilios');
$ordenIdParam = (int)($_GET['orden_id'] ?? 0);
$pacIdParam = (int)($_GET['paciente_id']?? 0);
$domIdParam = (int)($_GET['id'] ?? 0);
@@ -59,9 +60,11 @@ $domIdParam = (int)($_GET['id'] ?? 0);
<h1 id="header-titulo"><i class="fas fa-house-medical text-primary"></i> Domicilios</h1>
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
</div>
<?php if ($puedeEscribir): ?>
<button class="btn btn-primary btn-sm" id="btn-nuevo-domicilio" onclick="abrirFormulario()">
<i class="fas fa-plus me-1"></i> Nuevo Domicilio
</button>
<?php endif; ?>
</header>
<!-- Tabs -->
@@ -320,7 +323,7 @@ $domIdParam = (int)($_GET['id'] ?? 0);
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" name="valor_domicilio" id="dom-valor-dom"
min="0" step="1000" placeholder="0">
min="0" step="1" placeholder="0">
</div>
</div>
<div class="col-md-4">
@@ -328,7 +331,7 @@ $domIdParam = (int)($_GET['id'] ?? 0);
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" name="valor_copago" id="dom-valor-cop"
min="0" step="1000" placeholder="0">
min="0" step="1" placeholder="0">
</div>
</div>
<div class="col-md-4">
@@ -336,7 +339,7 @@ $domIdParam = (int)($_GET['id'] ?? 0);
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" name="copago_laboratorio" id="dom-copago-lab"
min="0" step="1000" placeholder="0">
min="0" step="1" placeholder="0">
</div>
</div>
</div>
@@ -583,6 +586,7 @@ function domQuitarOrden() {
_domRenderOrdenPreview();
}
const COLOR_DOM = { programado:'secondary', confirmado:'info', en_camino:'primary', en_domicilio:'warning', completado:'success', cancelado:'danger', reprogramado:'dark' };
const PUEDE_ESCRIBIR = <?= $puedeEscribir ? 'true' : 'false' ?>;
let domSeleccionado = <?= $domIdParam ?: 'null' ?>;
let _domActual = null; // objeto completo del domicilio activo en el panel de detalle
let paginaActual = 1;
+7 -5
View File
@@ -12,6 +12,7 @@ header('Pragma: no-cache');
$adminNombre = $_SESSION['admin_user']['full_name'] ?? 'Usuario';
$esAdmin = !isEnfermero();
$puedeEscribir = $esAdmin && hasModuleWrite('lab_formularios');
?>
<!DOCTYPE html>
<html lang="es">
@@ -66,7 +67,7 @@ $esAdmin = !isEnfermero();
<h1><i class="fas fa-wpforms text-primary"></i> Formularios</h1>
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
</div>
<?php if ($esAdmin): ?>
<?php if ($puedeEscribir): ?>
<button class="btn btn-primary btn-sm" onclick="builder.nuevo()">
<i class="fas fa-plus me-1"></i> Nuevo Formulario
</button>
@@ -260,6 +261,7 @@ $esAdmin = !isEnfermero();
// CONFIGURACIÓN
// ══════════════════════════════════════════════════════════════════════
const ES_ADMIN = <?= $esAdmin ? 'true' : 'false' ?>;
const PUEDE_ESCRIBIR = <?= $puedeEscribir ? 'true' : 'false' ?>;
// Tipos de campo disponibles
const TIPOS_CAMPO = [
@@ -336,7 +338,7 @@ function renderFormularios() {
grid.innerHTML = `<div class="col-12 text-center py-5 text-muted">
<i class="fas fa-wpforms fa-3x mb-3 opacity-25"></i>
<p>No hay formularios creados aún.</p>
${ES_ADMIN ? '<button class="btn btn-primary" onclick="builder.nuevo()"><i class="fas fa-plus me-1"></i>Crear primer formulario</button>' : ''}
${PUEDE_ESCRIBIR ? '<button class="btn btn-primary" onclick="builder.nuevo()"><i class="fas fa-plus me-1"></i>Crear primer formulario</button>' : ''}
</div>`;
return;
}
@@ -360,7 +362,7 @@ function renderFormularios() {
onclick="envio.abrir(${f.id})">
<i class="fas fa-paper-plane me-1"></i>Enviar
</button>
${ES_ADMIN ? `
${PUEDE_ESCRIBIR ? `
<button class="btn btn-sm btn-outline-primary" onclick="builder.editar(${f.id})"
title="Editar diseño"><i class="fas fa-edit"></i></button>
<button class="btn btn-sm btn-outline-danger" onclick="confirmarBorrar(${f.id},'${esc(f.nombre)}')"
@@ -404,12 +406,12 @@ const builder = {
},
nuevo() {
if (!ES_ADMIN) return;
if (!PUEDE_ESCRIBIR) return;
this._abrirVentana('lab_formulario_builder.php');
},
editar(id) {
if (!ES_ADMIN) return;
if (!PUEDE_ESCRIBIR) return;
this._abrirVentana(`lab_formulario_builder.php?id=${id}`);
},
+4 -1
View File
@@ -10,6 +10,7 @@ if (!isUserLoggedIn()) {
}
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
$puedeEscribir = hasModuleWrite('lab_ordenes');
$ordenIdParam = (int)($_GET['id'] ?? 0);
$estadoParam = $_GET['estado'] ?? '';
?>
@@ -184,6 +185,7 @@ const modalImg = new bootstrap.Modal('#modalImagen');
const FLUJO = ['pendiente','en_revision','autorizada','rechazada','en_domicilio','completada'];
const COLOR = { pendiente:'warning', en_revision:'info', autorizada:'success', rechazada:'danger', en_domicilio:'primary', completada:'secondary' };
const PUEDE_ESCRIBIR = <?= $puedeEscribir ? 'true' : 'false' ?>;
// ── Lista ──────────────────────────────────────────────────────────────────
async function cargarLista(pag = 1) {
@@ -345,7 +347,7 @@ async function verOrden(id) {
</div>`).join('') || '<p class="text-muted small mb-0">Sin historial</p>'}
</div>
${o.estado === 'autorizada' ? `
${o.estado === 'autorizada' && PUEDE_ESCRIBIR ? `
<div class="mt-3">
<a href="lab_domicilios.php?orden_id=${o.id}&paciente_id=${o.paciente_id}" class="btn btn-success w-100 btn-sm">
<i class="fas fa-house-medical me-1"></i> Crear domicilio para esta orden
@@ -357,6 +359,7 @@ async function verOrden(id) {
}
function renderBotones(o) {
if (!PUEDE_ESCRIBIR) return '';
const btns = [];
if (o.estado === 'pendiente')
btns.push(`<button class="btn btn-sm btn-info" onclick="accion(${o.id},'en_revision')"><i class="fas fa-magnifying-glass me-1"></i>Iniciar revisión</button>`);
+134
View File
@@ -243,6 +243,55 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- Reporte domicilios por enfermero -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 d-flex flex-wrap gap-2 align-items-end">
<h6 class="fw-semibold mb-0 me-auto">
<i class="fas fa-user-nurse me-2 text-success"></i>Domicilios por enfermero
</h6>
<div class="d-flex flex-wrap gap-2 align-items-end">
<div>
<label class="form-label small text-muted text-uppercase mb-1">Año</label>
<input type="number" id="enf-anio" class="form-control form-control-sm"
value="<?= date('Y') ?>" min="2020" max="2099" style="width:90px">
</div>
<div>
<label class="form-label small text-muted text-uppercase mb-1">Mes</label>
<select id="enf-mes" class="form-select form-select-sm" style="width:100px">
<option value="">Todos</option>
<option value="1">Enero</option><option value="2">Febrero</option>
<option value="3">Marzo</option><option value="4">Abril</option>
<option value="5">Mayo</option><option value="6">Junio</option>
<option value="7">Julio</option><option value="8">Agosto</option>
<option value="9">Septiembre</option><option value="10">Octubre</option>
<option value="11">Noviembre</option><option value="12">Diciembre</option>
</select>
</div>
<div>
<label class="form-label small text-muted text-uppercase mb-1">Enfermero/a</label>
<select id="enf-selector" class="form-select form-select-sm" style="min-width:160px">
<option value="">Todos</option>
</select>
</div>
<button class="btn btn-primary btn-sm" onclick="cargarReporteEnfermero()">
<i class="fas fa-sync-alt me-1"></i>Filtrar
</button>
<button class="btn btn-sm btn-outline-success" onclick="exportarReporteEnfermero()">
<i class="fas fa-file-excel me-1"></i>Excel
</button>
</div>
</div>
<div class="card-body p-0">
<div id="enf-tabla-wrap" style="overflow-x:auto">
<div class="text-center py-4 text-muted">
<i class="fas fa-spinner fa-spin"></i> Cargando…
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- KPIs principales -->
<!-- ══════════════════════════════════════════════════════════════ -->
@@ -429,6 +478,90 @@ const COLOR_EST = {
const MODULO_COLOR = { ordenes:'warning', domicilios:'success', pacientes:'primary', enfermeras:'info', asignaciones:'secondary' };
let logPag = 1;
// ── Reporte por enfermero ───────────────────────────────────────────────────
async function cargarReporteEnfermero() {
const wrap = document.getElementById('enf-tabla-wrap');
wrap.innerHTML = '<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>';
const anio = document.getElementById('enf-anio').value;
const mes = document.getElementById('enf-mes').value;
const enf = document.getElementById('enf-selector').value;
const params = new URLSearchParams();
if (anio) params.set('anio', anio);
if (mes) params.set('mes', mes);
if (enf) params.set('enfermera_id', enf);
try {
const r = await fetch(`api/lab/get_reporte_enfermero.php?${params}`);
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error');
// Poblar selector de enfermeras (sólo la primera vez)
const sel = document.getElementById('enf-selector');
if (sel.options.length === 1 && d.enfermeras?.length) {
d.enfermeras.forEach(e => {
const opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e.nombre_completo;
sel.appendChild(opt);
});
}
if (!d.filas?.length) {
wrap.innerHTML = '<p class="text-muted text-center py-3 small">Sin datos para los filtros seleccionados.</p>';
return;
}
wrap.innerHTML = `
<table class="table table-sm table-hover mb-0" style="font-size:.85rem">
<thead class="table-light">
<tr>
<th>Enfermero/a</th>
<th class="text-center">Período</th>
<th class="text-center">Total</th>
<th class="text-center text-success">Completados</th>
<th class="text-center text-danger">Cancelados</th>
<th class="text-center text-warning">En proceso</th>
<th class="text-center">Tasa éxito</th>
</tr>
</thead>
<tbody>
${d.filas.map(f => {
const color = f.tasa_pct >= 80 ? '#43a047' : f.tasa_pct >= 50 ? '#fb8c00' : '#e53935';
return `<tr>
<td class="fw-semibold">${esc(f.enfermera)}</td>
<td class="text-center text-muted">${esc(f.mes_nombre)} ${f.anio}</td>
<td class="text-center">${f.total}</td>
<td class="text-center text-success">${f.completados}</td>
<td class="text-center text-danger">${f.cancelados}</td>
<td class="text-center text-warning">${f.en_proceso}</td>
<td class="text-center">
<span class="fw-bold" style="color:${color}">${f.tasa_pct}%</span>
<div style="width:60px;height:5px;background:#f0f0f0;border-radius:3px;margin:2px auto 0">
<div style="width:${f.tasa_pct}%;height:100%;background:${color};border-radius:3px"></div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>`;
} catch (e) {
wrap.innerHTML = `<div class="alert alert-danger m-3 small">${esc(e.message)}</div>`;
}
}
function exportarReporteEnfermero() {
const anio = document.getElementById('enf-anio').value;
const mes = document.getElementById('enf-mes').value;
const enf = document.getElementById('enf-selector').value;
const params = new URLSearchParams({ export: 'domicilios', desde: `${anio || '2020'}-${mes ? String(mes).padStart(2,'0') : '01'}-01` });
if (mes) params.set('hasta', `${anio || new Date().getFullYear()}-${String(mes).padStart(2,'0')}-31`);
else params.set('hasta', `${anio || new Date().getFullYear()}-12-31`);
if (enf) params.set('enfermera_id', enf);
window.open(`lab_reportes.php?${params}`, '_blank');
}
// ── Carga todo en paralelo ──────────────────────────────────────────────────
async function cargarTodo() {
const desde = fecha('rpt-desde');
@@ -437,6 +570,7 @@ async function cargarTodo() {
cargarMetricas(desde, hasta),
cargarResumenOrdenes(desde, hasta),
cargarActividad(1),
cargarReporteEnfermero(),
]);
}
+39
View File
@@ -0,0 +1,39 @@
-- ============================================================
-- Migración: Roles de solo lectura para Formularios y Lab
-- Descripción: Agrega columna 'permission' a role_modules,
-- crea roles de lectura y sus asignaciones de módulos.
-- ============================================================
-- ── 1. Agregar columna permission a role_modules (si no existe) ─────────────
ALTER TABLE `role_modules`
ADD COLUMN IF NOT EXISTS `permission`
ENUM('read','write') NOT NULL DEFAULT 'write'
COMMENT 'Nivel de acceso al módulo: read = solo lectura, write = lectura y escritura';
-- ── 2. Nuevos roles de solo lectura ────────────────────────────────────────
INSERT IGNORE INTO `roles` (`name`, `slug`, `description`, `color`, `is_system`) VALUES
('Formularios (Lectura)', 'formularios_readonly', 'Ver y enviar formularios. Sin crear ni editar plantillas.', '#17a2b8', 0),
('Lab Solo Lectura', 'lab_readonly', 'Ver órdenes y domicilios. Sin crear, editar ni eliminar.', '#6c757d', 0);
-- ── 3. Módulos para rol Formularios (Lectura) ───────────────────────────────
INSERT IGNORE INTO `role_modules` (`role_id`, `module_slug`, `permission`)
SELECT r.id, 'lab_formularios', 'read'
FROM `roles` r WHERE r.slug = 'formularios_readonly';
-- ── 4. Módulos para rol Lab Solo Lectura ────────────────────────────────────
INSERT IGNORE INTO `role_modules` (`role_id`, `module_slug`, `permission`)
SELECT r.id, 'lab_ordenes', 'read'
FROM `roles` r WHERE r.slug = 'lab_readonly';
INSERT IGNORE INTO `role_modules` (`role_id`, `module_slug`, `permission`)
SELECT r.id, 'lab_domicilios', 'read'
FROM `roles` r WHERE r.slug = 'lab_readonly';
INSERT IGNORE INTO `role_modules` (`role_id`, `module_slug`, `permission`)
SELECT r.id, 'lab_pacientes', 'read'
FROM `roles` r WHERE r.slug = 'lab_readonly';
-- ── 5. También asignar acceso al dashboard a ambos roles nuevos ─────────────
INSERT IGNORE INTO `role_modules` (`role_id`, `module_slug`, `permission`)
SELECT r.id, 'lab_dashboard', 'read'
FROM `roles` r WHERE r.slug IN ('formularios_readonly', 'lab_readonly');
+6
View File
@@ -43,6 +43,12 @@ class BotService {
if ($fresh) $user = $fresh;
}
// ── Usuario bloqueado: descartar mensaje silenciosamente ──────────────
if (isset($user['status']) && $user['status'] === 'blocked') {
error_log("[BotService] processMessage - user_id={$userId} is blocked, ignoring message");
return;
}
try {
// Comprobar en la base de datos el estado más reciente de 'in_service' para evitar condiciones de carrera