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:
File diff suppressed because it is too large
Load Diff
@@ -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.*
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
+37
-12
@@ -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
|
||||
$modules = [];
|
||||
// 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);
|
||||
@@ -500,14 +507,15 @@ function authenticateUser($username, $password) {
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $admin['id'],
|
||||
'username' => $admin['username'],
|
||||
'full_name' => $admin['full_name'],
|
||||
'email' => $admin['email'],
|
||||
'role' => $roleSlug,
|
||||
'role_id' => $admin['role_id'] ?? null,
|
||||
'enfermera_id' => $admin['enfermera_id'] ?? null,
|
||||
'modules' => $modules,
|
||||
'id' => $admin['id'],
|
||||
'username' => $admin['username'],
|
||||
'full_name' => $admin['full_name'],
|
||||
'email' => $admin['email'],
|
||||
'role' => $roleSlug,
|
||||
'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
@@ -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&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, '"').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)
|
||||
|
||||
+104
-13
@@ -10,9 +10,10 @@ if (!isUserLoggedIn()) {
|
||||
header('Location: login.php'); exit;
|
||||
}
|
||||
|
||||
$usuario = $_SESSION['admin_user'];
|
||||
$rol = $usuario['role'] ?? 'admin';
|
||||
$enfId = (int)($usuario['enfermera_id'] ?? 0);
|
||||
$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'])) {
|
||||
@@ -386,8 +387,9 @@ $hoy = date('Y-m-d');
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const ENFERMERA_ID = <?= (int)$enfId ?>;
|
||||
const ROL = '<?= $rol ?>';
|
||||
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">
|
||||
<i class="fab fa-whatsapp"></i>
|
||||
</a>` : ''}
|
||||
? (()=>{
|
||||
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>`;
|
||||
})() : ''}
|
||||
<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); }
|
||||
},
|
||||
|
||||
|
||||
+12
-8
@@ -9,10 +9,11 @@ if (!isUserLoggedIn()) {
|
||||
exit;
|
||||
}
|
||||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
$ordenIdParam = (int)($_GET['orden_id'] ?? 0);
|
||||
$pacIdParam = (int)($_GET['paciente_id']?? 0);
|
||||
$domIdParam = (int)($_GET['id'] ?? 0);
|
||||
$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);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
@@ -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>
|
||||
@@ -582,7 +585,8 @@ function domQuitarOrden() {
|
||||
document.getElementById('dom-orden-input').value = '';
|
||||
_domRenderOrdenPreview();
|
||||
}
|
||||
const COLOR_DOM = { programado:'secondary', confirmado:'info', en_camino:'primary', en_domicilio:'warning', completado:'success', cancelado:'danger', reprogramado:'dark' };
|
||||
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;
|
||||
|
||||
+10
-8
@@ -10,8 +10,9 @@ if (isEnfermero() && !enfermeraId()) { header('Location: enfermero_portal.php');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? 'Usuario';
|
||||
$esAdmin = !isEnfermero();
|
||||
$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>
|
||||
@@ -259,7 +260,8 @@ $esAdmin = !isEnfermero();
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// CONFIGURACIÓN
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const ES_ADMIN = <?= $esAdmin ? 'true' : 'false' ?>;
|
||||
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}`);
|
||||
},
|
||||
|
||||
|
||||
+9
-6
@@ -9,9 +9,10 @@ if (!isUserLoggedIn()) {
|
||||
exit;
|
||||
}
|
||||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
$ordenIdParam = (int)($_GET['id'] ?? 0);
|
||||
$estadoParam = $_GET['estado'] ?? '';
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
$puedeEscribir = hasModuleWrite('lab_ordenes');
|
||||
$ordenIdParam = (int)($_GET['id'] ?? 0);
|
||||
$estadoParam = $_GET['estado'] ?? '';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
@@ -182,8 +183,9 @@ let ordenSeleccionada = <?= $ordenIdParam ?: 'null' ?>;
|
||||
const modalAccion = new bootstrap.Modal('#modalAccion');
|
||||
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 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>`);
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user