Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9749d726fa | ||
|
|
339ca8a40d |
+518
-5
@@ -1,9 +1,522 @@
|
||||
# Obsoleto
|
||||
# Documentación del Sistema de Laboratorio
|
||||
|
||||
Este documento quedó desactualizado y se conserva solo por historial de git.
|
||||
> **Sistema**: Laboratorio Clínico — Módulos de Agendamiento y Formularios
|
||||
> **Última actualización**: 27/03/2026
|
||||
|
||||
La documentación vigente está **dentro del sistema**, en el módulo Soporte:
|
||||
---
|
||||
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
## Tabla de contenidos
|
||||
|
||||
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
|
||||
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.*
|
||||
|
||||
+1
-6
@@ -36,12 +36,7 @@ RUN apk add --no-cache curl-dev \
|
||||
opcache
|
||||
|
||||
# Instalar Redis extension (versión fija para cache reproducible)
|
||||
# Se descarga por HTTPS de forma explícita: el filtro de red perimetral
|
||||
# responde 403 a los .tgz servidos por HTTP, lo que rompía "pecl install".
|
||||
RUN apk add --no-cache curl \
|
||||
&& curl -fsSL https://pecl.php.net/get/redis-6.0.2.tgz -o /tmp/redis-6.0.2.tgz \
|
||||
&& pecl install /tmp/redis-6.0.2.tgz \
|
||||
&& docker-php-ext-enable redis
|
||||
RUN pecl install redis-6.0.2 && docker-php-ext-enable redis
|
||||
|
||||
# Instalar Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# Documentación del proyecto
|
||||
|
||||
La documentación vive **dentro del sistema**, en el módulo Soporte:
|
||||
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
|
||||
Se escribe en Markdown, en `modules/soporte/docs/`, y se versiona con el código.
|
||||
|
||||
| Sección | Carpeta | Quién la ve |
|
||||
|---|---|---|
|
||||
| Manual de usuario | `docs/manual/` | Cualquier usuario autenticado |
|
||||
| Documentación técnica | `docs/tecnica/` | Administradores |
|
||||
| Arquitectura | `docs/arquitectura/` | Administradores |
|
||||
| Operación y soporte | `docs/operacion/` | Administradores |
|
||||
|
||||
## Agregar o editar una página
|
||||
|
||||
Creá un `.md` en la carpeta de la sección. El nombre lleva un prefijo numérico
|
||||
que solo sirve para ordenar:
|
||||
|
||||
modules/soporte/docs/tecnica/70-mi-tema.md
|
||||
|
||||
El título sale del primer encabezado `#` del archivo. No hay que registrar nada
|
||||
en ningún índice: se descubre solo.
|
||||
|
||||
## Limitar un documento a ciertos roles
|
||||
|
||||
Por defecto un documento hereda el permiso de su sección. Para restringirlo más,
|
||||
se declara al inicio del archivo:
|
||||
|
||||
---
|
||||
roles: enfermero, supervisor
|
||||
---
|
||||
|
||||
# Enfermeros — domicilios
|
||||
|
||||
Solo esos roles lo ven; para el resto no aparece en el índice ni es accesible
|
||||
por URL. Los administradores ven todo, siempre. Una sección que queda sin
|
||||
documentos visibles no se muestra.
|
||||
|
||||
## Contenido que se genera solo
|
||||
|
||||
Estos marcadores, en una línea propia, se reemplazan al cargar la página con
|
||||
datos leídos del código y de la base:
|
||||
|
||||
| Marcador | Qué inserta |
|
||||
|----------|-------------|
|
||||
| `{{modulos}}` | Módulos, con sus vistas y endpoints |
|
||||
| `{{endpoints}}` | Todos los endpoints por módulo |
|
||||
| `{{tablas}}` | Tablas de la base, agrupadas por prefijo |
|
||||
| `{{roles}}` | Roles, usuarios activos y sus permisos |
|
||||
| `{{servicios}}` | Clases de `core/`, `services/` y `classes/` |
|
||||
|
||||
Así los inventarios no pueden quedar desactualizados. La descripción de cada
|
||||
endpoint sale de su comentario de cabecera: si lo escribís bien, aparece bien.
|
||||
|
||||
## Documentos anteriores
|
||||
|
||||
`DOCUMENTACION_LAB.md` y `README_LAB.md` quedaron de una etapa previa y están
|
||||
desactualizados. `WEBHOOK_ENDPOINTS.md` se migró a la sección técnica.
|
||||
+125
-5
@@ -1,9 +1,129 @@
|
||||
# Obsoleto
|
||||
# Módulo Administrativo — Laboratorio Clínico
|
||||
|
||||
Este documento quedó desactualizado y se conserva solo por historial de git.
|
||||
Módulo add-on para el sistema de chatbot WhatsApp que permite gestionar órdenes médicas recibidas como imágenes, domicilios, enfermeras y pacientes.
|
||||
|
||||
La documentación vigente está **dentro del sistema**, en el módulo Soporte:
|
||||
---
|
||||
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
## Instalación
|
||||
|
||||
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
|
||||
### 1. Ejecutar migraciones de base de datos
|
||||
|
||||
```bash
|
||||
php migrations/20260302_lab_run_migrations.php
|
||||
```
|
||||
|
||||
Crea 7 tablas nuevas sin modificar las existentes:
|
||||
- `lab_pacientes`
|
||||
- `lab_enfermeras`
|
||||
- `lab_ordenes_medicas`
|
||||
- `lab_domicilios`
|
||||
- `lab_asignaciones`
|
||||
- `lab_autorizaciones`
|
||||
- `lab_actividad_admin`
|
||||
|
||||
Para deshacer:
|
||||
```bash
|
||||
php migrations/20260302_lab_run_migrations.php --rollback
|
||||
```
|
||||
|
||||
### 2. Verificar instalación
|
||||
|
||||
Accede desde el navegador (con sesión admin activa):
|
||||
```
|
||||
https://tu-servidor/lab_status.php
|
||||
```
|
||||
|
||||
O desde CLI:
|
||||
```bash
|
||||
php lab_status.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archivos del módulo
|
||||
|
||||
### Vistas PHP
|
||||
| Archivo | Descripción |
|
||||
|---|---|
|
||||
| `lab_dashboard.php` | Panel principal con estadísticas en tiempo real |
|
||||
| `lab_ordenes.php` | Gestión de órdenes médicas (estados, imágenes, historial) |
|
||||
| `lab_pacientes.php` | CRUD de pacientes, vinculación con usuarios WhatsApp |
|
||||
| `lab_domicilios.php` | Agenda de domicilios y asignación de enfermeras |
|
||||
| `lab_enfermeras.php` | CRUD de enfermeras y visualización de agenda diaria |
|
||||
| `lab_reportes.php` | Trazabilidad, log de actividad, exportación CSV |
|
||||
| `lab_status.php` | Verificador de estado del módulo |
|
||||
|
||||
### Clases (models)
|
||||
Ubicadas en `classes/lab/`:
|
||||
- `ActividadAdmin.php` — Base de trazabilidad
|
||||
- `Paciente.php` — Modelo de pacientes
|
||||
- `Enfermera.php` — Modelo de enfermeras
|
||||
- `OrdenMedica.php` — Modelo de órdenes médicas con flujo de estados
|
||||
- `Domicilio.php` — Modelo de domicilios con flujo de estados
|
||||
- `Asignacion.php` — Modelo de asignaciones enfermera ↔ domicilio
|
||||
|
||||
### API REST
|
||||
Ubicados en `api/lab/`:
|
||||
| Endpoint | Método | Descripción |
|
||||
|---|---|---|
|
||||
| `get_pacientes.php` | GET | Lista paginada de pacientes |
|
||||
| `save_paciente.php` | POST | Crear/actualizar paciente |
|
||||
| `get_ordenes.php` | GET | Lista/detalle de órdenes |
|
||||
| `save_orden.php` | POST | Crear/actualizar orden |
|
||||
| `autorizar_orden.php` | POST | Cambiar estado de una orden |
|
||||
| `get_domicilios.php` | GET | Lista/detalle de domicilios |
|
||||
| `save_domicilio.php` | POST | Crear/actualizar domicilio |
|
||||
| `get_enfermeras.php` | GET | Lista de enfermeras + agenda |
|
||||
| `save_enfermera.php` | POST | Crear/actualizar enfermera |
|
||||
| `get_asignaciones.php` | GET | Asignaciones por fecha |
|
||||
| `save_asignacion.php` | POST | Asignar/liberar/completar enfermera |
|
||||
| `get_actividad.php` | GET | Log de actividad con filtros |
|
||||
| `get_stats.php` | GET | Estadísticas para dashboard |
|
||||
| `crear_desde_whatsapp.php` | GET/POST | Crear orden desde conversación activa |
|
||||
|
||||
---
|
||||
|
||||
## Flujos de estado
|
||||
|
||||
### Órdenes médicas
|
||||
```
|
||||
pendiente → en_revision → autorizada → en_domicilio → completada
|
||||
↘ rechazada
|
||||
```
|
||||
|
||||
### Domicilios
|
||||
```
|
||||
programado → confirmado → en_camino → en_domicilio → completado
|
||||
↘ cancelado
|
||||
↘ reprogramado
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integración con el chatbot
|
||||
|
||||
En `conversations.php`, los mensajes de imagen entrantes tienen un botón **<i class="fas fa-flask"></i>** (verde) en las acciones del mensaje. Al hacer click:
|
||||
|
||||
1. Se abre un modal con la imagen adjunta
|
||||
2. El operador busca o selecciona un paciente (o usa el contacto de la conversación)
|
||||
3. Completa datos opcionales (médico, exámenes, ayuno)
|
||||
4. Se crea la orden en estado `pendiente`
|
||||
|
||||
---
|
||||
|
||||
## Exportaciones CSV
|
||||
|
||||
Disponibles desde `lab_reportes.php`:
|
||||
- **Órdenes médicas** del período — incluye estado, médico, exámenes
|
||||
- **Domicilios** del período — incluye enfermera asignada, dirección, estado
|
||||
- **Pacientes** — catálogo completo con total de órdenes
|
||||
|
||||
---
|
||||
|
||||
## Requisitos
|
||||
|
||||
- PHP 8.2+
|
||||
- MariaDB 10.11+ (o MySQL 8+)
|
||||
- Bootstrap 5.3 (ya incluido en el sistema)
|
||||
- Font Awesome 6.4 (ya incluido en el sistema)
|
||||
- `uploads/media/` con permisos de escritura (755/775)
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
# Webhook de WhatsApp
|
||||
|
||||
_Migrado de `WEBHOOK_ENDPOINTS.md` (raíz del repositorio), donde vivía suelto._
|
||||
|
||||
# Webhook WhatsApp — Endpoints y Características
|
||||
|
||||
## Endpoint principal
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Migración: simplificar formulario de Tomas Prolongadas (id=15)
|
||||
* - Campo _c8j2g16 pasa de radio/checkbox a texto libre ("Tipo de examen")
|
||||
* - Se eliminan las condiciones de todos los separadores (siempre visibles)
|
||||
* Auto-elimina al ejecutar. Acceder como admin.
|
||||
*/
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
if (!isUserLoggedIn() || !in_array($_SESSION['admin_user']['role'] ?? '', ['admin','superadmin'], true)) {
|
||||
http_response_code(403); die('Acceso denegado.');
|
||||
}
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$row = $pdo->query("SELECT esquema FROM lab_formularios WHERE id = 15 LIMIT 1")->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) { die('Formulario id=15 no encontrado.'); }
|
||||
|
||||
$esquema = json_decode($row['esquema'], true);
|
||||
if (!is_array($esquema)) { die('Esquema inválido.'); }
|
||||
|
||||
$cambios = 0;
|
||||
foreach ($esquema as &$campo) {
|
||||
$id = $campo['id'] ?? '';
|
||||
$tipo = $campo['tipo'] ?? '';
|
||||
|
||||
// Separadores con condición sobre _c8j2g16 → quitar condición
|
||||
if ($tipo === 'separador' && isset($campo['condicion'])) {
|
||||
$condCampo = $campo['condicion']['campo_id'] ?? '';
|
||||
if ($condCampo === '_c8j2g16') {
|
||||
unset($campo['condicion']);
|
||||
$cambios++;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($campo);
|
||||
|
||||
$pdo->prepare("UPDATE lab_formularios SET esquema = ? WHERE id = 15")
|
||||
->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
|
||||
|
||||
@unlink(__FILE__);
|
||||
|
||||
echo '<p style="font-family:sans-serif;padding:2rem">
|
||||
<span style="color:green;font-size:1.2rem">✅ Migración completada</span><br><br>
|
||||
<strong>' . $cambios . ' campos modificados</strong>:<br>
|
||||
• Condiciones eliminadas de separadores de tomas<br>
|
||||
• Campo _c8j2g16 conservado como selector (+ botón "Añadir tipo")<br><br>
|
||||
<small style="color:#666">Este archivo fue eliminado automáticamente.</small><br><br>
|
||||
<a href="lab_formularios.php" style="color:#0d6efd">← Volver a Formularios</a>
|
||||
</p>';
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Restaura las condiciones eliminadas por _migrate_tomas_simplify.php
|
||||
* Para cada separador cuyo label empiece con un tipo de examen conocido
|
||||
* (ej. "Curva de Glicemia · Minuto 0") → agrega condicion sobre _c8j2g16.
|
||||
* Auto-elimina al ejecutar. Acceder como admin.
|
||||
*/
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
if (!isUserLoggedIn() || !in_array($_SESSION['admin_user']['role'] ?? '', ['admin','superadmin'], true)) {
|
||||
http_response_code(403); die('Acceso denegado.');
|
||||
}
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$row = $pdo->query("SELECT esquema FROM lab_formularios WHERE id = 15 LIMIT 1")->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) { die('Formulario id=15 no encontrado.'); }
|
||||
|
||||
$esquema = json_decode($row['esquema'], true);
|
||||
if (!is_array($esquema)) { die('Esquema inválido.'); }
|
||||
|
||||
// Obtener opciones del selector de examen
|
||||
$examOptions = [];
|
||||
foreach ($esquema as $c) {
|
||||
if (($c['id'] ?? '') === '_c8j2g16') { $examOptions = $c['options'] ?? []; break; }
|
||||
}
|
||||
if (empty($examOptions)) { die('No se encontraron tipos de examen en _c8j2g16.'); }
|
||||
|
||||
$restaurados = 0;
|
||||
foreach ($esquema as &$campo) {
|
||||
if (($campo['tipo'] ?? '') !== 'separador') continue;
|
||||
if (isset($campo['condicion'])) continue; // ya tiene condicion
|
||||
|
||||
$label = $campo['label'] ?? '';
|
||||
$prefix = trim(explode('·', $label)[0] ?? '');
|
||||
if (!$prefix) continue;
|
||||
|
||||
// Buscar si el prefijo corresponde exactamente a un tipo de examen
|
||||
if (in_array($prefix, $examOptions, true)) {
|
||||
$campo['condicion'] = [
|
||||
'campo_id' => '_c8j2g16',
|
||||
'valores' => [$prefix],
|
||||
];
|
||||
$restaurados++;
|
||||
}
|
||||
}
|
||||
unset($campo);
|
||||
|
||||
$pdo->prepare("UPDATE lab_formularios SET esquema = ? WHERE id = 15")
|
||||
->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
|
||||
|
||||
@unlink(__FILE__);
|
||||
|
||||
echo '<p style="font-family:sans-serif;padding:2rem">
|
||||
<span style="color:green;font-size:1.2rem">✅ Condiciones restauradas</span><br><br>
|
||||
<strong>' . $restaurados . ' separadores</strong> actualizados con condicion sobre _c8j2g16.<br>
|
||||
<small style="color:#666">Tipos de examen encontrados: ' . implode(', ', array_map('htmlspecialchars', $examOptions)) . '</small><br><br>
|
||||
<small style="color:#999">Este archivo fue eliminado automáticamente.</small><br><br>
|
||||
<a href="lab_formularios.php" style="color:#0d6efd">← Volver a Formularios</a>
|
||||
</p>';
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Script de migración de un solo uso — ELIMINAR DESPUÉS DE EJECUTAR
|
||||
* Acceder como admin para registrar lab_tomas_config en system_modules.
|
||||
*/
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
if (!isUserLoggedIn() || !in_array($_SESSION['admin_user']['role'] ?? '', ['admin','superadmin'], true)) {
|
||||
http_response_code(403); die('Acceso denegado.');
|
||||
}
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
|
||||
$row = $pdo->query("SELECT MAX(sort_order) AS mx FROM system_modules WHERE category = 'clinico'")->fetch(PDO::FETCH_ASSOC);
|
||||
$nextOrder = (int)($row['mx'] ?? 50) + 10;
|
||||
|
||||
$pdo->prepare("
|
||||
INSERT INTO system_modules (slug, name, icon, category, route, is_active, sort_order, description)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name=VALUES(name), icon=VALUES(icon), category=VALUES(category),
|
||||
route=VALUES(route), is_active=1, sort_order=VALUES(sort_order), description=VALUES(description)
|
||||
")->execute([
|
||||
'lab_tomas_config',
|
||||
'Tipos de Examen (Tomas)',
|
||||
'fas fa-vials',
|
||||
'clinico',
|
||||
'/lab_tomas_config.php',
|
||||
$nextOrder,
|
||||
'Configurar tipos de examen y ciclos de tomas prolongadas (F-LAB-28)',
|
||||
]);
|
||||
|
||||
// Auto-eliminar el script
|
||||
@unlink(__FILE__);
|
||||
|
||||
echo '<p style="font-family:sans-serif;color:green;padding:2rem">
|
||||
✅ Módulo <strong>lab_tomas_config</strong> registrado en system_modules (sort_order='.$nextOrder.').<br>
|
||||
<small>Este archivo fue eliminado automáticamente.</small><br><br>
|
||||
<a href="lab_tomas_config.php">Ir a Tipos de Examen →</a>
|
||||
</p>';
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/add_exam_option.php
|
||||
* Añade una opción al campo selector de tipo de examen (_c8j2g16) del formulario id=15.
|
||||
* Body JSON: { exam_name: string }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireAdmin();
|
||||
|
||||
$body = inputJson();
|
||||
$examName = trim($body['exam_name'] ?? '');
|
||||
|
||||
if (!$examName) jsonError('exam_name requerido.');
|
||||
if (strlen($examName) > 80) jsonError('Nombre demasiado largo (máx 80 chars).');
|
||||
if (!preg_match('/\S/', $examName)) jsonError('Nombre inválido.');
|
||||
|
||||
$db = Database::getInstance();
|
||||
$row = $db->fetch("SELECT esquema FROM lab_formularios WHERE id = 15 LIMIT 1");
|
||||
if (!$row) jsonError('Formulario no encontrado.', 404);
|
||||
|
||||
$esquema = json_decode($row['esquema'], true);
|
||||
if (!is_array($esquema)) jsonError('Esquema inválido.', 500);
|
||||
|
||||
$updated = false;
|
||||
foreach ($esquema as &$campo) {
|
||||
if (($campo['id'] ?? '') !== '_c8j2g16') continue;
|
||||
$opts = $campo['options'] ?? [];
|
||||
if (in_array($examName, $opts, true)) jsonError("El tipo \"$examName\" ya existe.");
|
||||
$opts[] = $examName;
|
||||
$campo['options'] = $opts;
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($campo);
|
||||
|
||||
if (!$updated) jsonError('Campo selector de examen no encontrado en el formulario.', 500);
|
||||
|
||||
$db->getConnection()->prepare("UPDATE lab_formularios SET esquema = ? WHERE id = 15")
|
||||
->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
|
||||
|
||||
jsonOk(['exam_name' => $examName], "Tipo \"$examName\" añadido.");
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* /api/lab/ciudades.php
|
||||
* GET ?action=list [solo_activas=1] → lista ciudades
|
||||
* POST {action:save, id?, nombre} → crear / renombrar
|
||||
* POST {action:toggle, id} → activar / desactivar
|
||||
* POST {action:delete, id} → eliminar (solo si no hay pacientes)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$soloActivas = isset($_GET['solo_activas']) && $_GET['solo_activas'] == '1';
|
||||
$sql = 'SELECT id, nombre, activa, orden FROM lab_ciudades' . ($soloActivas ? ' WHERE activa = 1' : '') . ' ORDER BY nombre';
|
||||
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
jsonOk(['ciudades' => $rows]);
|
||||
}
|
||||
|
||||
requireMethod('POST');
|
||||
requireAdmin();
|
||||
|
||||
$body = inputJson();
|
||||
$action = trim($body['action'] ?? '');
|
||||
|
||||
if ($action === 'save') {
|
||||
$nombre = trim($body['nombre'] ?? '');
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$nombre) jsonError('nombre requerido.');
|
||||
if (strlen($nombre) > 100) jsonError('nombre demasiado largo (máx 100).');
|
||||
|
||||
if ($id) {
|
||||
$pdo->prepare('UPDATE lab_ciudades SET nombre = ? WHERE id = ?')->execute([$nombre, $id]);
|
||||
jsonOk(['id' => $id], 'Ciudad actualizada.');
|
||||
} else {
|
||||
$st = $pdo->prepare('INSERT INTO lab_ciudades (nombre) VALUES (?)');
|
||||
try {
|
||||
$st->execute([$nombre]);
|
||||
} catch (\PDOException $e) {
|
||||
if ($e->getCode() == 23000) jsonError('Ya existe una ciudad con ese nombre.');
|
||||
throw $e;
|
||||
}
|
||||
jsonOk(['id' => (int)$pdo->lastInsertId()], 'Ciudad creada.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'toggle') {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) jsonError('id requerido.');
|
||||
$pdo->prepare('UPDATE lab_ciudades SET activa = NOT activa WHERE id = ?')->execute([$id]);
|
||||
jsonOk([], 'Estado actualizado.');
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) jsonError('id requerido.');
|
||||
$uso = $pdo->prepare('SELECT COUNT(*) FROM lab_pacientes WHERE ciudad = (SELECT nombre FROM lab_ciudades WHERE id = ?)');
|
||||
$uso->execute([$id]);
|
||||
if ((int)$uso->fetchColumn() > 0) jsonError('No se puede eliminar: hay pacientes con esta ciudad. Desactívela en su lugar.');
|
||||
$pdo->prepare('DELETE FROM lab_ciudades WHERE id = ?')->execute([$id]);
|
||||
jsonOk([], 'Ciudad eliminada.');
|
||||
}
|
||||
|
||||
jsonError('action inválida.');
|
||||
@@ -21,53 +21,6 @@ requireAuthentication();
|
||||
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
||||
$db = Database::getInstance();
|
||||
|
||||
/**
|
||||
* Le pide el teléfono a quien lo tiene oculto en WhatsApp, una sola vez.
|
||||
*
|
||||
* Solo aplica a quien se identifica con un BSUID: de esa persona no tenemos
|
||||
* número, y sin él el laboratorio no puede llamarla. Se le manda el botón que
|
||||
* Meta dispone para esto y ella decide si lo comparte; si acepta, el webhook
|
||||
* recibe el teléfono y lo vincula solo.
|
||||
*
|
||||
* No se insiste: pedir los datos una vez es razonable, repetirlo en cada
|
||||
* trámite es acoso. Tampoco se interrumpe la creación de la ficha si el envío
|
||||
* falla, porque la ficha es lo importante.
|
||||
*
|
||||
* @return string qué pasó, para que la interfaz lo pueda mostrar
|
||||
*/
|
||||
function pedirContactoSiHaceFalta(Database $db, int $userId): string {
|
||||
$u = $db->fetch(
|
||||
'SELECT phone_number, contacto_pedido_at FROM users WHERE id = ?',
|
||||
[$userId]
|
||||
);
|
||||
if (!$u) return 'usuario_no_encontrado';
|
||||
if (!esBsuid($u['phone_number'])) return 'no_hace_falta'; // ya tenemos su número
|
||||
if (!empty($u['contacto_pedido_at'])) return 'ya_se_pidio';
|
||||
|
||||
try {
|
||||
require_once __DIR__ . '/../../services/WhatsAppService.php';
|
||||
$wa = new WhatsAppService();
|
||||
$texto = getConfigFromDB(
|
||||
'whatsapp_texto_pedir_contacto',
|
||||
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?'
|
||||
);
|
||||
|
||||
$r = $wa->pedirContacto($u['phone_number'], $texto);
|
||||
if (!$r) {
|
||||
error_log('[crear_desde_whatsapp] WhatsApp rechazó la solicitud de contacto del usuario ' . $userId);
|
||||
return 'fallo_envio';
|
||||
}
|
||||
|
||||
// Se marca solo si Meta aceptó: si falló, hay que poder reintentarlo
|
||||
$db->update('users', ['contacto_pedido_at' => date('Y-m-d H:i:s')], 'id = ?', [$userId]);
|
||||
return 'pedido';
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('[crear_desde_whatsapp] Error pidiendo el contacto: ' . $e->getMessage());
|
||||
return 'fallo_envio';
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET: solo_paciente ─────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||
@@ -104,16 +57,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$pacienteRepo = new Paciente();
|
||||
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
||||
$paciente = $pacienteRepo->obtener($pacienteId);
|
||||
|
||||
// Si la persona oculta su teléfono, la ficha queda sin número. Es el momento
|
||||
// de pedírselo: se le manda el botón de WhatsApp una sola vez.
|
||||
$contactoPedido = pedirContactoSiHaceFalta($db, (int) $conv['user_id']);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'paciente' => $paciente,
|
||||
'contacto_pedido' => $contactoPedido,
|
||||
]);
|
||||
echo json_encode(['success' => true, 'paciente' => $paciente]);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* /api/lab/empresa_subgrupos.php
|
||||
*
|
||||
* GET ?nit_empresa= → subgrupos de la empresa
|
||||
* POST {action:save, ...} → upsert subgrupo
|
||||
* POST {action:delete, id} → elimina subgrupo
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($method === 'GET') {
|
||||
$nit = trim($_GET['nit_empresa'] ?? '');
|
||||
if ($nit === '') jsonError('nit_empresa requerido');
|
||||
$pdo = db();
|
||||
$s = $pdo->prepare(
|
||||
"SELECT es.*, ti.nombre AS tarifa_nombre
|
||||
FROM lab_empresa_subgrupos es
|
||||
LEFT JOIN lab_tarifas_id ti ON ti.id = es.tarifa_id
|
||||
WHERE es.nit_empresa = ?
|
||||
ORDER BY es.subgrupo ASC"
|
||||
);
|
||||
$s->execute([$nit]);
|
||||
jsonOk(['subgrupos' => $s->fetchAll(PDO::FETCH_ASSOC)]);
|
||||
}
|
||||
|
||||
requireMethod('POST');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$action = $data['action'] ?? '';
|
||||
$pdo = db();
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if ($id <= 0) jsonError('id requerido');
|
||||
$pdo->prepare("DELETE FROM lab_empresa_subgrupos WHERE id = ?")->execute([$id]);
|
||||
jsonOk(['deleted' => true]);
|
||||
}
|
||||
|
||||
if ($action === 'save') {
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$nitEmpresa = trim($data['nit_empresa'] ?? '');
|
||||
$subgrupo = trim($data['subgrupo'] ?? '');
|
||||
if ($nitEmpresa === '') jsonError('nit_empresa requerido');
|
||||
if ($subgrupo === '') jsonError('subgrupo requerido');
|
||||
|
||||
$tarifaId = isset($data['tarifa_id']) && $data['tarifa_id'] !== '' ? (int)$data['tarifa_id'] : null;
|
||||
$refSub = trim($data['ref_subgrupo'] ?? '') ?: null;
|
||||
$codCon = trim($data['cod_contrato'] ?? '') ?: null;
|
||||
|
||||
if ($id > 0) {
|
||||
$pdo->prepare(
|
||||
"UPDATE lab_empresa_subgrupos
|
||||
SET subgrupo=?, tarifa_id=?, ref_subgrupo=?, cod_contrato=?
|
||||
WHERE id=? AND nit_empresa=?"
|
||||
)->execute([$subgrupo, $tarifaId, $refSub, $codCon, $id, $nitEmpresa]);
|
||||
} else {
|
||||
$pdo->prepare(
|
||||
"INSERT INTO lab_empresa_subgrupos (nit_empresa, subgrupo, tarifa_id, ref_subgrupo, cod_contrato)
|
||||
VALUES (?, ?, ?, ?, ?)"
|
||||
)->execute([$nitEmpresa, $subgrupo, $tarifaId, $refSub, $codCon]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
}
|
||||
jsonOk(['id' => $id]);
|
||||
}
|
||||
|
||||
jsonError('Acción no reconocida', 400);
|
||||
@@ -1,162 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* /api/lab/empresas.php
|
||||
*
|
||||
* GET ?action=list [search, activa, page, limit] → lista paginada
|
||||
* GET ?action=get &nit= → empresa + subgrupos
|
||||
* POST {action:save, nit, nombre, ...} → upsert empresa
|
||||
* POST {action:toggle, nit} → activa/inactiva
|
||||
* POST {action:delete, nit} → elimina (si sin recepciones)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// ─── GET ─────────────────────────────────────────────────────────────────────
|
||||
if ($method === 'GET') {
|
||||
$action = $_GET['action'] ?? 'list';
|
||||
$pdo = db();
|
||||
|
||||
if ($action === 'get') {
|
||||
$nit = trim($_GET['nit'] ?? '');
|
||||
if ($nit === '') jsonError('nit requerido');
|
||||
|
||||
$e = $pdo->prepare(
|
||||
"SELECT e.*, ti.nombre AS tarifa_nombre
|
||||
FROM lab_empresas e
|
||||
LEFT JOIN lab_tarifas_id ti ON ti.id = e.tarifa_id
|
||||
WHERE e.nit = ?"
|
||||
);
|
||||
$e->execute([$nit]);
|
||||
$empresa = $e->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$empresa) jsonError('Empresa no encontrada', 404);
|
||||
|
||||
$s = $pdo->prepare(
|
||||
"SELECT es.*, ti.nombre AS tarifa_nombre
|
||||
FROM lab_empresa_subgrupos es
|
||||
LEFT JOIN lab_tarifas_id ti ON ti.id = es.tarifa_id
|
||||
WHERE es.nit_empresa = ?
|
||||
ORDER BY es.subgrupo ASC"
|
||||
);
|
||||
$s->execute([$nit]);
|
||||
$empresa['subgrupos'] = $s->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonOk(['empresa' => $empresa]);
|
||||
}
|
||||
|
||||
// list
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$activa = isset($_GET['activa']) && $_GET['activa'] !== '' ? (int)$_GET['activa'] : null;
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$limit = max(1, min(100, (int)($_GET['limit'] ?? 30)));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$where[] = '(e.nombre LIKE ? OR e.nit LIKE ? OR e.razon_social LIKE ?)';
|
||||
$like = "%{$search}%";
|
||||
$params[] = $like; $params[] = $like; $params[] = $like;
|
||||
}
|
||||
if ($activa !== null) {
|
||||
$where[] = 'e.activa = ?';
|
||||
$params[] = $activa;
|
||||
}
|
||||
|
||||
$wSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$total = $pdo->prepare("SELECT COUNT(*) FROM lab_empresas e $wSql");
|
||||
$total->execute($params);
|
||||
$totalRows = (int)$total->fetchColumn();
|
||||
|
||||
$rows = $pdo->prepare(
|
||||
"SELECT e.nit, e.nombre, e.razon_social, e.tarifa_id, ti.nombre AS tarifa_nombre,
|
||||
e.descuento_pct, e.codigo_eps, e.tipo_usuario, e.req_autoriza, e.activa,
|
||||
(SELECT COUNT(*) FROM lab_empresa_subgrupos es WHERE es.nit_empresa = e.nit) AS total_subgrupos
|
||||
FROM lab_empresas e
|
||||
LEFT JOIN lab_tarifas_id ti ON ti.id = e.tarifa_id
|
||||
$wSql
|
||||
ORDER BY e.nombre ASC
|
||||
LIMIT $limit OFFSET $offset"
|
||||
);
|
||||
$rows->execute($params);
|
||||
|
||||
jsonOk([
|
||||
'empresas' => $rows->fetchAll(PDO::FETCH_ASSOC),
|
||||
'total' => $totalRows,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'pages' => (int)ceil($totalRows / $limit),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── POST ────────────────────────────────────────────────────────────────────
|
||||
requireMethod('POST');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$action = $data['action'] ?? '';
|
||||
$pdo = db();
|
||||
|
||||
if ($action === 'toggle') {
|
||||
$nit = trim($data['nit'] ?? '');
|
||||
if ($nit === '') jsonError('nit requerido');
|
||||
$pdo->prepare("UPDATE lab_empresas SET activa = 1 - activa WHERE nit = ?")->execute([$nit]);
|
||||
$activa = (int)$pdo->prepare("SELECT activa FROM lab_empresas WHERE nit=?")->execute([$nit]);
|
||||
$row = $pdo->prepare("SELECT activa FROM lab_empresas WHERE nit=?");
|
||||
$row->execute([$nit]);
|
||||
jsonOk(['activa' => (bool)(int)$row->fetchColumn()]);
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$nit = trim($data['nit'] ?? '');
|
||||
if ($nit === '') jsonError('nit requerido');
|
||||
// Block deletion if empresa has recepciones (historical)
|
||||
$chk = $pdo->prepare("SELECT COUNT(*) FROM lab_recepciones WHERE nit_empresa = ? LIMIT 1");
|
||||
$chk->execute([$nit]);
|
||||
if ((int)$chk->fetchColumn() > 0) {
|
||||
jsonError('No se puede eliminar: la empresa tiene recepciones históricas registradas.', 409);
|
||||
}
|
||||
$pdo->prepare("DELETE FROM lab_empresas WHERE nit = ?")->execute([$nit]);
|
||||
jsonOk(['deleted' => true]);
|
||||
}
|
||||
|
||||
if ($action === 'save') {
|
||||
$nit = trim($data['nit'] ?? '');
|
||||
$nombre = trim($data['nombre'] ?? '');
|
||||
if ($nit === '') jsonError('El NIT es obligatorio');
|
||||
if ($nombre === '') jsonError('El nombre es obligatorio');
|
||||
|
||||
$fields = [
|
||||
'nombre' => $nombre,
|
||||
'razon_social' => trim($data['razon_social'] ?? '') ?: null,
|
||||
'tarifa_id' => isset($data['tarifa_id']) && $data['tarifa_id'] !== '' ? (int)$data['tarifa_id'] : null,
|
||||
'descuento_pct' => isset($data['descuento_pct']) ? (float)$data['descuento_pct'] : 0,
|
||||
'codigo_eps' => trim($data['codigo_eps'] ?? '') ?: null,
|
||||
'tipo_usuario' => trim($data['tipo_usuario'] ?? '') ?: null,
|
||||
'tipo_usuario_sispro'=> trim($data['tipo_usuario_sispro']?? '') ?: null,
|
||||
'cod_contrato' => trim($data['cod_contrato'] ?? '') ?: null,
|
||||
'cod_tercero' => trim($data['cod_tercero'] ?? '') ?: null,
|
||||
'centro_costo' => trim($data['centro_costo'] ?? '') ?: null,
|
||||
'req_autoriza' => isset($data['req_autoriza']) ? (int)(bool)$data['req_autoriza'] : 0,
|
||||
'activa' => isset($data['activa']) ? (int)(bool)$data['activa'] : 1,
|
||||
];
|
||||
|
||||
// Check if exists
|
||||
$exists = $pdo->prepare("SELECT nit FROM lab_empresas WHERE nit = ?");
|
||||
$exists->execute([$nit]);
|
||||
$isNew = !$exists->fetch();
|
||||
|
||||
if ($isNew) {
|
||||
$cols = implode(', ', array_map(fn($k) => "`$k`", array_keys($fields)));
|
||||
$ph = implode(', ', array_fill(0, count($fields), '?'));
|
||||
$pdo->prepare("INSERT INTO lab_empresas (nit, $cols) VALUES (?, $ph)")
|
||||
->execute(array_merge([$nit], array_values($fields)));
|
||||
} else {
|
||||
$sets = implode(', ', array_map(fn($k) => "`$k` = ?", array_keys($fields)));
|
||||
$pdo->prepare("UPDATE lab_empresas SET $sets WHERE nit = ?")
|
||||
->execute(array_merge(array_values($fields), [$nit]));
|
||||
}
|
||||
|
||||
jsonOk(['nit' => $nit, 'created' => $isNew]);
|
||||
}
|
||||
|
||||
jsonError('Acción no reconocida', 400);
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* /api/lab/eps.php
|
||||
* GET ?action=list [solo_activas=1] → lista EPS
|
||||
* POST {action:save, id?, nombre} → crear / renombrar
|
||||
* POST {action:toggle, id} → activar / desactivar
|
||||
* POST {action:delete, id} → eliminar (solo si no hay pacientes)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$soloActivas = isset($_GET['solo_activas']) && $_GET['solo_activas'] == '1';
|
||||
$sql = 'SELECT id, nombre, activa, orden FROM lab_eps' . ($soloActivas ? ' WHERE activa = 1' : '') . ' ORDER BY nombre';
|
||||
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
jsonOk(['eps' => $rows]);
|
||||
}
|
||||
|
||||
requireMethod('POST');
|
||||
requireAdmin();
|
||||
|
||||
$body = inputJson();
|
||||
$action = trim($body['action'] ?? '');
|
||||
|
||||
if ($action === 'save') {
|
||||
$nombre = trim($body['nombre'] ?? '');
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$nombre) jsonError('nombre requerido.');
|
||||
if (strlen($nombre) > 120) jsonError('nombre demasiado largo (máx 120).');
|
||||
|
||||
if ($id) {
|
||||
$pdo->prepare('UPDATE lab_eps SET nombre = ? WHERE id = ?')->execute([$nombre, $id]);
|
||||
jsonOk(['id' => $id], 'EPS actualizada.');
|
||||
} else {
|
||||
$st = $pdo->prepare('INSERT INTO lab_eps (nombre) VALUES (?)');
|
||||
try {
|
||||
$st->execute([$nombre]);
|
||||
} catch (\PDOException $e) {
|
||||
if ($e->getCode() == 23000) jsonError('Ya existe una EPS con ese nombre.');
|
||||
throw $e;
|
||||
}
|
||||
jsonOk(['id' => (int)$pdo->lastInsertId()], 'EPS creada.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'toggle') {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) jsonError('id requerido.');
|
||||
$pdo->prepare('UPDATE lab_eps SET activa = NOT activa WHERE id = ?')->execute([$id]);
|
||||
jsonOk([], 'Estado actualizado.');
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) jsonError('id requerido.');
|
||||
$uso = $pdo->prepare('SELECT COUNT(*) FROM lab_pacientes WHERE eps = (SELECT nombre FROM lab_eps WHERE id = ?)');
|
||||
$uso->execute([$id]);
|
||||
if ((int)$uso->fetchColumn() > 0) jsonError('No se puede eliminar: hay pacientes con esta EPS. Desactívela en su lugar.');
|
||||
$pdo->prepare('DELETE FROM lab_eps WHERE id = ?')->execute([$id]);
|
||||
jsonOk([], 'EPS eliminada.');
|
||||
}
|
||||
|
||||
jsonError('action inválida.');
|
||||
@@ -32,8 +32,6 @@ if (!$body) $body = $_POST;
|
||||
$envioId = (int)($body['envio_id'] ?? 0);
|
||||
$campoId = trim($body['campo_id'] ?? '');
|
||||
$svg = $body['svg'] ?? '';
|
||||
$proNombre = trim($body['pro_nombre'] ?? '');
|
||||
$proCedula = trim($body['pro_cedula'] ?? '');
|
||||
|
||||
if (!$envioId) err('envio_id requerido');
|
||||
if ($campoId === '') err('campo_id requerido');
|
||||
@@ -74,8 +72,6 @@ if (!$campoDef || ($campoDef['tipo'] ?? '') !== 'firma_profesional') {
|
||||
// ── Actualizar datos_cliente: guardar solo en el campo firmado ──
|
||||
$datos = json_decode($envio['datos_cliente'] ?? '{}', true) ?? [];
|
||||
$datos[$campoId . '_svg'] = $svg;
|
||||
if ($proNombre !== '') $datos['_pro_nombre'] = $proNombre;
|
||||
if ($proCedula !== '') $datos['_pro_cedula'] = $proCedula;
|
||||
|
||||
$nuevosDatos = json_encode($datos, JSON_UNESCAPED_UNICODE);
|
||||
$nuevoEstado = $envio['estado'] === 'firmado' ? 'firmado' : 'firmado';
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/lab/get_examenes_rips.php?cedula=X
|
||||
* Consulta al RIPS Manager los exámenes registrados en los últimos 5 minutos
|
||||
* para la cédula indicada, y los mapea a exam_tipos locales por codigo_legacy.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
$cedula = trim($_GET['cedula'] ?? '');
|
||||
if (!$cedula) jsonError('cedula requerida', 400);
|
||||
|
||||
// ── 1. Buscar en cache local (exámenes enviados por el scheduler de RIPS) ─────
|
||||
$examenes = [];
|
||||
$fuenteCache = false;
|
||||
|
||||
$cacheRow = db()->prepare(
|
||||
"SELECT datos, recepcion_id, hora_recepcion
|
||||
FROM rips_examenes_pendientes
|
||||
WHERE numero_documento = ?
|
||||
AND DATE(created_at) = CURDATE()
|
||||
AND created_at >= NOW() - INTERVAL 30 MINUTE
|
||||
AND turno_id IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$cacheRow->execute([$cedula]);
|
||||
$cache = $cacheRow->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($cache) {
|
||||
$examenes = json_decode($cache['datos'], true) ?: [];
|
||||
$fuenteCache = true;
|
||||
}
|
||||
|
||||
// ── 2. Si no hay cache, consultar RIPS Manager ────────────────────────────────
|
||||
if (!$examenes) {
|
||||
$ripsUrl = defined('RIPS_MANAGER_URL') ? rtrim(RIPS_MANAGER_URL, '/') : '';
|
||||
if (!$ripsUrl) jsonError('RIPS_MANAGER_URL no configurado en el servidor', 503);
|
||||
|
||||
$url = $ripsUrl . '/pacientes/examenes?cedula=' . urlencode($cedula);
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => 'X-Lab-Key: ' . LAB_SYNC_KEY . "\r\n",
|
||||
'timeout' => 8,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$resp = @file_get_contents($url, false, $ctx);
|
||||
if ($resp === false) jsonError('No se pudo conectar con RIPS Manager', 503);
|
||||
|
||||
$data = json_decode($resp, true);
|
||||
if (!($data['ok'] ?? false)) {
|
||||
jsonError($data['error'] ?? 'Error en RIPS Manager', 502);
|
||||
}
|
||||
|
||||
$examenes = $data['examenes'] ?? [];
|
||||
}
|
||||
if (!$examenes) {
|
||||
jsonOk(['encontrados' => [], 'no_mapeados' => [], 'total_rips' => 0]);
|
||||
}
|
||||
|
||||
// Mapear COD_EXAMEN → exam_tipos por codigo_legacy
|
||||
$codigos = array_values(array_unique(array_filter(
|
||||
array_map(fn($e) => trim($e['cod_examen'] ?? ''), $examenes)
|
||||
)));
|
||||
|
||||
$encontrados = [];
|
||||
$no_mapeados = [];
|
||||
|
||||
if ($codigos) {
|
||||
$ph = implode(',', array_fill(0, count($codigos), '?'));
|
||||
$stmt = db()->prepare(
|
||||
"SELECT id AS exam_tipo_id, codigo, nombre,
|
||||
COALESCE(codigo_legacy, codigo) AS match_key
|
||||
FROM exam_tipos
|
||||
WHERE (codigo_legacy IN ($ph) OR (codigo_legacy IS NULL AND codigo IN ($ph)))
|
||||
AND activo = 1"
|
||||
);
|
||||
$stmt->execute(array_merge($codigos, $codigos));
|
||||
$mapa = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$mapa[trim($r['match_key'])] = $r;
|
||||
}
|
||||
foreach ($codigos as $cod) {
|
||||
if (isset($mapa[$cod])) {
|
||||
$encontrados[] = [
|
||||
'exam_tipo_id' => (int)$mapa[$cod]['exam_tipo_id'],
|
||||
'codigo' => $mapa[$cod]['codigo'],
|
||||
'nombre' => $mapa[$cod]['nombre'],
|
||||
'cod_rips' => $cod,
|
||||
];
|
||||
} else {
|
||||
$no_mapeados[] = $cod;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$meta = $examenes[0] ?? [];
|
||||
|
||||
// ── Médico ordenante ──────────────────────────────────────────────────────────
|
||||
$medicoObj = null;
|
||||
$docidmedico = trim($meta['medico_docidmedico'] ?? '');
|
||||
if ($docidmedico) {
|
||||
$stm = db()->prepare(
|
||||
"SELECT id, codigo, CONCAT(nombres, ' ', apellidos) AS nombre_completo, cod_especialidad
|
||||
FROM medicos WHERE docidmedico = ? LIMIT 1"
|
||||
);
|
||||
$stm->execute([$docidmedico]);
|
||||
$row = $stm->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) {
|
||||
$medicoObj = [
|
||||
'id' => (int)$row['id'],
|
||||
'codigo' => $row['codigo'],
|
||||
'nombre' => $row['nombre_completo'],
|
||||
'especialidad' => $row['cod_especialidad'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empresa / EPS ─────────────────────────────────────────────────────────────
|
||||
$empresaObj = null;
|
||||
$nitEmpresa = trim($meta['nit_empresa'] ?? '');
|
||||
if ($nitEmpresa) {
|
||||
$stm = db()->prepare(
|
||||
"SELECT e.nit, e.nombre, e.razon_social, e.tarifa_id,
|
||||
ti.nombre AS tarifa_nombre, e.descuento_pct,
|
||||
e.tipo_usuario, e.req_autoriza, e.activa
|
||||
FROM lab_empresas e
|
||||
LEFT JOIN lab_tarifas_id ti ON ti.id = e.tarifa_id
|
||||
WHERE e.nit = ? AND e.activa = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
$stm->execute([$nitEmpresa]);
|
||||
$row = $stm->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) {
|
||||
$empresaObj = $row;
|
||||
$empresaObj['activa'] = (bool)$empresaObj['activa'];
|
||||
$empresaObj['req_autoriza'] = (bool)$empresaObj['req_autoriza'];
|
||||
$empresaObj['descuento_pct'] = (float)$empresaObj['descuento_pct'];
|
||||
}
|
||||
}
|
||||
|
||||
jsonOk([
|
||||
'encontrados' => $encontrados,
|
||||
'no_mapeados' => $no_mapeados,
|
||||
'total_rips' => count($examenes),
|
||||
'recepcion_id' => $meta['recepcion_id'] ?? null,
|
||||
'hora' => $meta['hora'] ?? null,
|
||||
'fuente' => $fuenteCache ? 'cache' : 'rips',
|
||||
'diagnostico_cod' => $meta['diagnostico_cod'] ?? null,
|
||||
'diagnostico_nombre' => $meta['diagnostico_nombre'] ?? null,
|
||||
'medico' => $medicoObj,
|
||||
'empresa' => $empresaObj,
|
||||
'valor_total' => isset($meta['valor_total']) ? (float)$meta['valor_total'] : null,
|
||||
]);
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?user_id=X — Devuelve la firma_svg de un usuario (solo admins).
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
|
||||
$userId = (int)($_GET['user_id'] ?? 0);
|
||||
if ($userId <= 0) jsonError('user_id requerido.');
|
||||
|
||||
$db = Database::getInstance()->getConnection();
|
||||
$row = $db->prepare("SELECT firma_svg FROM admin_users WHERE id = ? LIMIT 1");
|
||||
$row->execute([$userId]);
|
||||
$data = $row->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonOk(['firma_svg' => $data['firma_svg'] ?? null]);
|
||||
@@ -21,7 +21,6 @@ $users = $db->fetchAll("
|
||||
u.enfermera_id,
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
(u.firma_svg IS NOT NULL) AS tiene_firma,
|
||||
r.name AS role_name,
|
||||
r.color AS role_color,
|
||||
r.slug AS role_slug,
|
||||
@@ -34,7 +33,6 @@ $users = $db->fetchAll("
|
||||
|
||||
foreach ($users as &$u) {
|
||||
$u['is_active'] = (bool)$u['is_active'];
|
||||
$u['tiene_firma'] = (bool)$u['tiene_firma'];
|
||||
$u['role_name'] = $u['role_name'] ?? ucfirst($u['role'] ?? 'admin');
|
||||
$u['role_color'] = $u['role_color'] ?? '#0d6efd';
|
||||
}
|
||||
|
||||
@@ -13,17 +13,11 @@ try {
|
||||
jsonOk(['data' => [$row]]);
|
||||
}
|
||||
|
||||
if (isset($_GET['stats'])) {
|
||||
jsonOk(['stats' => $pac->stats()]);
|
||||
}
|
||||
|
||||
$busqueda = trim($_GET['busqueda'] ?? $_GET['search'] ?? '');
|
||||
$pagina = max(1, (int)($_GET['page'] ?? $_GET['pagina'] ?? 1));
|
||||
$por = max(1, min(100, (int)($_GET['limit'] ?? $_GET['por_pagina'] ?? 30)));
|
||||
$origen = in_array($_GET['origen'] ?? '', ['manual', 'lab', 'whatsapp'], true)
|
||||
? $_GET['origen'] : '';
|
||||
|
||||
jsonOk($pac->listar($busqueda, $pagina, $por, $origen));
|
||||
jsonOk($pac->listar($busqueda, $pagina, $por));
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/ingest_diagnosticos.php
|
||||
* Recibe un lote de diagnósticos CIE-10 desde RIPS Manager y los upserta en cie10_diagnosticos.
|
||||
* Body: { "rows": [{"cod": "A000", "concepto": "..."}, ...] }
|
||||
*/
|
||||
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
error_reporting(E_ERROR | E_PARSE);
|
||||
ini_set('display_errors', '0');
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
ob_clean();
|
||||
echo json_encode(['ok' => false, 'error' => 'Método no permitido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$keyHeader = $_SERVER['HTTP_X_LAB_KEY'] ?? $_SERVER['HTTP_X_LAB_SYNC_KEY'] ?? '';
|
||||
if (!$keyHeader || !hash_equals(LAB_SYNC_KEY, $keyHeader)) {
|
||||
http_response_code(401);
|
||||
ob_clean();
|
||||
echo json_encode(['ok' => false, 'error' => 'API key inválida']);
|
||||
exit;
|
||||
}
|
||||
|
||||
function db(): \PDO {
|
||||
return Database::getInstance()->getConnection();
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($data) || empty($data['rows']) || !is_array($data['rows'])) {
|
||||
http_response_code(400);
|
||||
ob_clean();
|
||||
echo json_encode(['ok' => false, 'error' => 'Body inválido: se espera {rows: [...]}']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO cie10_diagnosticos (cod_diag, concepto)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE concepto = VALUES(concepto)"
|
||||
);
|
||||
if (!$stmt) {
|
||||
$info = $pdo->errorInfo();
|
||||
throw new \RuntimeException("Prepare falló [{$info[0]}]: {$info[2]}");
|
||||
}
|
||||
|
||||
$insertados = 0;
|
||||
foreach ($data['rows'] as $row) {
|
||||
$cod = trim($row['cod'] ?? '');
|
||||
$concepto = trim($row['concepto'] ?? '');
|
||||
if (!$cod || !$concepto) continue;
|
||||
$stmt->execute([$cod, $concepto]);
|
||||
$insertados++;
|
||||
}
|
||||
|
||||
ob_clean();
|
||||
echo json_encode(['ok' => true, 'insertados' => $insertados]);
|
||||
} catch (\Throwable $e) {
|
||||
ob_clean();
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/ingest_paciente.php
|
||||
* Endpoint server-to-server para ingesta de pacientes desde RIPS Manager.
|
||||
* Autenticación: header X-Lab-Key: <LAB_SYNC_KEY> (sin sesión de usuario).
|
||||
*
|
||||
* Body JSON (todos opcionales excepto nombre_completo en creación):
|
||||
* { numero_documento, tipo_documento, nombre_completo, telefono, email,
|
||||
* fecha_nacimiento, genero, direccion, ciudad, eps, origen }
|
||||
*
|
||||
* Respuesta:
|
||||
* { ok, action: "created"|"updated"|"skipped", id, message }
|
||||
*/
|
||||
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../classes/lab/Paciente.php';
|
||||
|
||||
error_reporting(E_ERROR | E_PARSE);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
|
||||
register_shutdown_function(function () {
|
||||
$err = error_get_last();
|
||||
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
ob_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => 'Error interno: ' . $err['message']]);
|
||||
}
|
||||
});
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, X-Lab-Key, X-Lab-Sync-Key');
|
||||
header('Access-Control-Max-Age: 86400');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'Método no permitido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Autenticación por API key ────────────────────────────────────────────────
|
||||
$keyHeader = $_SERVER['HTTP_X_LAB_KEY']
|
||||
?? $_SERVER['HTTP_X_LAB_SYNC_KEY']
|
||||
?? '';
|
||||
|
||||
if (!$keyHeader || !hash_equals(LAB_SYNC_KEY, $keyHeader)) {
|
||||
http_response_code(401);
|
||||
ob_clean();
|
||||
echo json_encode(['ok' => false, 'error' => 'API key inválida']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Leer body ────────────────────────────────────────────────────────────────
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
http_response_code(400);
|
||||
ob_clean();
|
||||
echo json_encode(['ok' => false, 'error' => 'Body JSON inválido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Normalizar campos ────────────────────────────────────────────────────────
|
||||
$campos = [];
|
||||
|
||||
if (!empty($data['nombre_completo'])) {
|
||||
$campos['nombre_completo'] = mb_strtoupper(trim($data['nombre_completo']));
|
||||
}
|
||||
if (!empty($data['numero_documento'])) {
|
||||
$campos['numero_documento'] = trim($data['numero_documento']);
|
||||
}
|
||||
if (!empty($data['tipo_documento'])) {
|
||||
$tiposValidos = ['CC', 'CE', 'TI', 'PA', 'NIT', 'RC', 'MS'];
|
||||
$t = strtoupper(trim($data['tipo_documento']));
|
||||
$campos['tipo_documento'] = in_array($t, $tiposValidos) ? $t : 'CC';
|
||||
}
|
||||
if (!empty($data['telefono'])) {
|
||||
$tel = preg_replace('/[^0-9+]/', '', $data['telefono']);
|
||||
if (strlen($tel) >= 6) {
|
||||
$campos['telefono'] = $tel;
|
||||
}
|
||||
}
|
||||
if (!empty($data['email']) && filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
$e = strtolower(trim($data['email']));
|
||||
// Ignorar emails de relleno generados por RIPS
|
||||
if (!str_contains($e, '@sinregistro.co') && !str_contains($e, 'sinregistro')) {
|
||||
$campos['email'] = $e;
|
||||
}
|
||||
}
|
||||
if (!empty($data['fecha_nacimiento'])) {
|
||||
$fn = trim($data['fecha_nacimiento']);
|
||||
// Acepta dd/mm/yyyy o yyyy-mm-dd
|
||||
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $fn, $m)) {
|
||||
$campos['fecha_nacimiento'] = "{$m[3]}-{$m[2]}-{$m[1]}";
|
||||
} elseif (preg_match('/^\d{4}-\d{2}-\d{2}$/', $fn)) {
|
||||
$campos['fecha_nacimiento'] = $fn;
|
||||
}
|
||||
}
|
||||
if (!empty($data['genero'])) {
|
||||
$g = strtoupper(trim($data['genero']));
|
||||
if (in_array($g, ['M', 'F', 'O'])) {
|
||||
$campos['genero'] = $g;
|
||||
}
|
||||
}
|
||||
if (!empty($data['direccion'])) {
|
||||
$campos['direccion'] = trim($data['direccion']);
|
||||
}
|
||||
if (!empty($data['ciudad'])) {
|
||||
$campos['ciudad'] = trim($data['ciudad']);
|
||||
}
|
||||
if (!empty($data['eps'])) {
|
||||
$campos['eps'] = trim($data['eps']);
|
||||
}
|
||||
// origen solo se aplica en creación, nunca en update
|
||||
$origenValidos = ['manual', 'lab', 'whatsapp'];
|
||||
$campos['origen'] = in_array($data['origen'] ?? '', $origenValidos, true)
|
||||
? $data['origen'] : 'lab';
|
||||
|
||||
// ── Modo: "insertar" (solo nuevos) | "upsert" (crea o actualiza) ─────────────
|
||||
$modo = trim($data['modo'] ?? 'upsert');
|
||||
if (!in_array($modo, ['insertar', 'upsert'], true)) {
|
||||
$modo = 'upsert';
|
||||
}
|
||||
|
||||
// ── Exámenes opcionales (vienen del scheduler de RIPS) ───────────────────────
|
||||
$examenesRaw = $data['examenes'] ?? null;
|
||||
$examenesGuardados = 0;
|
||||
|
||||
function db(): \PDO {
|
||||
return Database::getInstance()->getConnection();
|
||||
}
|
||||
|
||||
function guardarExamenesPendientes(string $doc, array $examenes): int {
|
||||
if (!$examenes || !$doc) return 0;
|
||||
$pdo = db();
|
||||
|
||||
$stDel = $pdo->prepare(
|
||||
"DELETE FROM rips_examenes_pendientes
|
||||
WHERE numero_documento = ? AND DATE(created_at) = CURDATE()"
|
||||
);
|
||||
if (!$stDel) {
|
||||
$info = $pdo->errorInfo();
|
||||
throw new \RuntimeException("rips_examenes_pendientes no existe o error: {$info[2]}");
|
||||
}
|
||||
$stDel->execute([$doc]);
|
||||
|
||||
$meta = $examenes[0] ?? [];
|
||||
$stIns = $pdo->prepare(
|
||||
"INSERT INTO rips_examenes_pendientes
|
||||
(numero_documento, datos, recepcion_id, hora_recepcion)
|
||||
VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
if (!$stIns) {
|
||||
$info = $pdo->errorInfo();
|
||||
throw new \RuntimeException("INSERT rips_examenes_pendientes falló: {$info[2]}");
|
||||
}
|
||||
$stIns->execute([
|
||||
$doc,
|
||||
json_encode($examenes, JSON_UNESCAPED_UNICODE),
|
||||
$meta['recepcion_id'] ?? null,
|
||||
$meta['hora'] ?? null,
|
||||
]);
|
||||
return count($examenes);
|
||||
}
|
||||
|
||||
// ── UPSERT / INSERT-ONLY ─────────────────────────────────────────────────────
|
||||
try {
|
||||
$pac = new Paciente();
|
||||
|
||||
$existente = null;
|
||||
if (!empty($campos['numero_documento'])) {
|
||||
$existente = $pac->porDocumento($campos['numero_documento']);
|
||||
}
|
||||
|
||||
ob_clean();
|
||||
|
||||
if ($existente) {
|
||||
if (!empty($examenesRaw) && is_array($examenesRaw)) {
|
||||
$examenesGuardados = guardarExamenesPendientes($campos['numero_documento'], $examenesRaw);
|
||||
}
|
||||
if ($modo === 'insertar') {
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'action' => 'skipped',
|
||||
'id' => $existente['id'],
|
||||
'message' => 'Paciente ya existe',
|
||||
'examenes_guardados' => $examenesGuardados,
|
||||
]);
|
||||
} else {
|
||||
// Nunca pisar origen en update — conservar el que ya tiene en BD
|
||||
unset($campos['origen']);
|
||||
$pac->actualizar($existente['id'], $campos, null);
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'action' => 'updated',
|
||||
'id' => $existente['id'],
|
||||
'message' => 'Paciente actualizado',
|
||||
'examenes_guardados' => $examenesGuardados,
|
||||
]);
|
||||
}
|
||||
} elseif (!empty($campos['nombre_completo'])) {
|
||||
$id = $pac->crear($campos, null);
|
||||
if (!empty($examenesRaw) && is_array($examenesRaw)) {
|
||||
$examenesGuardados = guardarExamenesPendientes($campos['numero_documento'], $examenesRaw);
|
||||
}
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'action' => 'created',
|
||||
'id' => $id,
|
||||
'message' => 'Paciente creado',
|
||||
'examenes_guardados' => $examenesGuardados,
|
||||
]);
|
||||
} else {
|
||||
http_response_code(422);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'action' => 'skipped',
|
||||
'error' => 'Sin número de documento ni nombre: registro omitido',
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
ob_clean();
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/marcar_rips_usado.php
|
||||
* Marca el registro RIPS de una cédula como consumido por un turno.
|
||||
* Body: { cedula, turno_id }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$cedula = trim($body['cedula'] ?? '');
|
||||
$turnoId = (int)($body['turno_id'] ?? 0);
|
||||
|
||||
if (!$cedula || !$turnoId) jsonError('cedula y turno_id requeridos', 400);
|
||||
|
||||
db()->prepare(
|
||||
"UPDATE rips_examenes_pendientes
|
||||
SET turno_id = ?
|
||||
WHERE numero_documento = ?
|
||||
AND DATE(created_at) = CURDATE()
|
||||
AND turno_id IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1"
|
||||
)->execute([$turnoId, $cedula]);
|
||||
|
||||
jsonOk(['marcado' => true]);
|
||||
@@ -55,18 +55,12 @@ try {
|
||||
if (!empty($datos['id'])) {
|
||||
// Actualización general
|
||||
$id = (int)$datos['id'];
|
||||
// Enfermero puede editar domicilios que creó o que tiene asignados
|
||||
// Enfermero solo puede editar domicilios que él mismo creó
|
||||
if (userRole() === 'enfermero') {
|
||||
$dbCheck = Database::getInstance();
|
||||
$chk = $dbCheck->fetchOne(
|
||||
"SELECT d.creado_por,
|
||||
(SELECT COUNT(*) FROM lab_asignaciones a
|
||||
WHERE a.domicilio_id = d.id AND a.enfermera_id = ?) AS asignado
|
||||
FROM lab_domicilios d WHERE d.id = ?",
|
||||
[enfermeraId(), $id]
|
||||
);
|
||||
if (!$chk || ((int)($chk['creado_por'] ?? 0) !== adminId() && !(int)$chk['asignado'])) {
|
||||
jsonError('Solo puedes editar domicilios que agendaste o que tienes asignados.');
|
||||
$chk = $dbCheck->fetchOne("SELECT creado_por FROM lab_domicilios WHERE id = ?", [$id]);
|
||||
if (!$chk || (int)($chk['creado_por'] ?? 0) !== adminId()) {
|
||||
jsonError('Solo puedes editar domicilios que tú mismo agendaste.');
|
||||
}
|
||||
}
|
||||
unset($datos['id'], $datos['solo_estado']);
|
||||
@@ -97,19 +91,6 @@ try {
|
||||
$eid = enfermeraId();
|
||||
if ($eid) $datos['enfermera_id'] = $eid;
|
||||
}
|
||||
// Asignar número de orden D-YYYYMMDD-NNN si no viene uno
|
||||
if (empty($datos['numero_orden'])) {
|
||||
$pdo = db();
|
||||
$prefix = 'D-' . date('Ymd') . '-';
|
||||
$st = $pdo->prepare(
|
||||
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo
|
||||
FROM lab_domicilios WHERE numero_orden LIKE ?"
|
||||
);
|
||||
$st->execute([$prefix . '%']);
|
||||
$ultimo = (int)($st->fetch(\PDO::FETCH_ASSOC)['ultimo'] ?? 0);
|
||||
$datos['numero_orden'] = $prefix . str_pad($ultimo + 1, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$id = $dom->crear($datos, $admin);
|
||||
|
||||
// Crear asignación automática si se indicó enfermera_id
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Admin guarda/borra la firma pre-configurada de cualquier usuario.
|
||||
* Body JSON: { user_id: 5, firma_svg: "data:image/png;base64,..." }
|
||||
* { user_id: 5, _borrar: true }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$userId = (int)($body['user_id'] ?? 0);
|
||||
if ($userId <= 0) jsonError('user_id requerido.');
|
||||
|
||||
$db = Database::getInstance()->getConnection();
|
||||
|
||||
if (!empty($body['_borrar'])) {
|
||||
$db->prepare("UPDATE admin_users SET firma_svg = NULL WHERE id = ?")->execute([$userId]);
|
||||
jsonOk(['mensaje' => 'Firma eliminada.']);
|
||||
}
|
||||
|
||||
$svg = $body['firma_svg'] ?? '';
|
||||
if (strlen($svg) < 100) jsonError('Firma requerida.');
|
||||
if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
|
||||
jsonError('Formato de firma no válido.');
|
||||
}
|
||||
|
||||
$db->prepare("UPDATE admin_users SET firma_svg = ? WHERE id = ?")->execute([$svg, $userId]);
|
||||
jsonOk(['mensaje' => 'Firma guardada correctamente.']);
|
||||
@@ -1,139 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_tomas_config.php
|
||||
* Gestiona los tipos de examen del formulario de Tomas Prolongadas (lab_formularios.id=15).
|
||||
*
|
||||
* Body JSON:
|
||||
* action string 'add_exam' | 'delete_exam'
|
||||
* exam_name string Nombre del nuevo tipo de examen
|
||||
* tomas array [{label, tipo:'minutos'|'hora_fija', valor}] (solo add_exam)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
requireMethod('POST');
|
||||
|
||||
$body = inputJson();
|
||||
$action = trim($body['action'] ?? '');
|
||||
|
||||
if (!in_array($action, ['add_exam', 'delete_exam'], true)) jsonError('action inválida.');
|
||||
|
||||
$db = Database::getInstance();
|
||||
$row = $db->fetch("SELECT id, esquema FROM lab_formularios WHERE id = 15 LIMIT 1");
|
||||
if (!$row) jsonError('Formulario de tomas no encontrado.', 404);
|
||||
|
||||
$esquema = json_decode($row['esquema'], true);
|
||||
if (!is_array($esquema)) jsonError('Esquema del formulario no válido.', 500);
|
||||
|
||||
// ── Localizar el campo selector de tipo de examen ────────────
|
||||
$idxSelector = null;
|
||||
foreach ($esquema as $i => $c) {
|
||||
if (($c['id'] ?? '') === '_c8j2g16') { $idxSelector = $i; break; }
|
||||
}
|
||||
if ($idxSelector === null) jsonError('Campo selector de examen (_c8j2g16) no encontrado.', 500);
|
||||
|
||||
// ── ADD EXAM ─────────────────────────────────────────────────
|
||||
if ($action === 'add_exam') {
|
||||
$examName = trim($body['exam_name'] ?? '');
|
||||
$tomas = $body['tomas'] ?? [];
|
||||
|
||||
if (!$examName) jsonError('exam_name requerido.');
|
||||
if (strlen($examName) > 80) jsonError('exam_name demasiado largo (máx 80 chars).');
|
||||
if (!is_array($tomas) || empty($tomas)) jsonError('tomas requeridas.');
|
||||
if (count($tomas) > 20) jsonError('Máximo 20 tomas por examen.');
|
||||
|
||||
// Verificar que el examen no exista ya
|
||||
$currentOptions = $esquema[$idxSelector]['options'] ?? [];
|
||||
if (in_array($examName, $currentOptions, true)) {
|
||||
jsonError("El tipo de examen '$examName' ya existe.");
|
||||
}
|
||||
|
||||
// Validar tomas
|
||||
foreach ($tomas as $i => $t) {
|
||||
$tipo = $t['tipo'] ?? '';
|
||||
$valor = $t['valor'] ?? '';
|
||||
$label = trim($t['label'] ?? '');
|
||||
if (!in_array($tipo, ['minutos', 'hora_fija'], true)) jsonError("Toma $i: tipo inválido.");
|
||||
if (!$label) jsonError("Toma $i: label requerido.");
|
||||
if ($tipo === 'minutos' && (!is_numeric($valor) || (int)$valor < 0))
|
||||
jsonError("Toma $i: valor de minutos inválido.");
|
||||
if ($tipo === 'hora_fija' && !preg_match('/^\d{1,2}:\d{2}$/', $valor))
|
||||
jsonError("Toma $i: hora_fija debe ser HH:MM.");
|
||||
}
|
||||
|
||||
// Prefijo corto para IDs (basado en nombre del examen, sanitizado)
|
||||
$prefix = '_' . substr(preg_replace('/[^a-z0-9]/i', '', strtolower($examName)), 0, 8) . '_';
|
||||
$uid = substr(md5($examName . microtime()), 0, 4);
|
||||
|
||||
// 1. Agregar opción al campo selector
|
||||
$esquema[$idxSelector]['options'][] = $examName;
|
||||
|
||||
// 2. Agregar campos al final del esquema
|
||||
$condCampoId = '_c8j2g16';
|
||||
foreach ($tomas as $idx => $t) {
|
||||
$label = trim($t['label']);
|
||||
$tipo = $t['tipo'];
|
||||
$valor = $t['valor'];
|
||||
|
||||
// Construir label del separador
|
||||
if ($tipo === 'minutos') {
|
||||
$sepLabel = "$examName · Minuto $valor";
|
||||
} else {
|
||||
// hora_fija: convertir HH:MM a "H:MM a.m./p.m."
|
||||
[$hh, $mm] = explode(':', $valor);
|
||||
$h = (int)$hh; $ampm = $h >= 12 ? 'p.m.' : 'a.m.';
|
||||
$h12 = $h > 12 ? $h - 12 : ($h === 0 ? 12 : $h);
|
||||
$sepLabel = "$examName · {$h12}:{$mm} {$ampm}";
|
||||
}
|
||||
|
||||
$sepId = $prefix . 's' . $idx . $uid;
|
||||
$horaId = $prefix . 'h' . $idx . $uid;
|
||||
$obsId = $prefix . 'o' . $idx . $uid;
|
||||
$firmaId = $prefix . 'f' . $idx . $uid;
|
||||
|
||||
$esquema[] = [
|
||||
'id' => $sepId,
|
||||
'tipo' => 'separador',
|
||||
'label' => $sepLabel,
|
||||
'condicion'=> ['campo_id' => $condCampoId, 'valores' => [$examName]],
|
||||
];
|
||||
$esquema[] = ['id' => $horaId, 'tipo' => 'hora', 'label' => 'Hora de toma'];
|
||||
$esquema[] = ['id' => $obsId, 'tipo' => 'texto', 'label' => 'Observaciones', 'placeholder' => '', 'required' => false];
|
||||
$esquema[] = ['id' => $firmaId, 'tipo' => 'firma_profesional', 'label' => 'Firma del profesional'];
|
||||
}
|
||||
|
||||
$db->getConnection()->prepare(
|
||||
"UPDATE lab_formularios SET esquema = ? WHERE id = 15"
|
||||
)->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
|
||||
|
||||
jsonOk([
|
||||
'exam_name' => $examName,
|
||||
'tomas_count' => count($tomas),
|
||||
'options_count'=> count($esquema[$idxSelector]['options']),
|
||||
], "Examen '$examName' agregado con " . count($tomas) . " tomas.");
|
||||
}
|
||||
|
||||
// ── DELETE EXAM ──────────────────────────────────────────────
|
||||
if ($action === 'delete_exam') {
|
||||
$examName = trim($body['exam_name'] ?? '');
|
||||
if (!$examName) jsonError('exam_name requerido.');
|
||||
|
||||
$options = $esquema[$idxSelector]['options'] ?? [];
|
||||
if (!in_array($examName, $options, true)) jsonError("El examen '$examName' no existe.");
|
||||
|
||||
// Eliminar opción del selector
|
||||
$esquema[$idxSelector]['options'] = array_values(array_filter($options, fn($o) => $o !== $examName));
|
||||
|
||||
// Eliminar separadores condicionados únicamente a este examen
|
||||
$esquema = array_values(array_filter($esquema, function($c) use ($examName) {
|
||||
$cond = $c['condicion'] ?? null;
|
||||
if (!$cond) return true;
|
||||
$vals = $cond['valores'] ?? [];
|
||||
return !(count($vals) === 1 && $vals[0] === $examName);
|
||||
}));
|
||||
|
||||
$db->getConnection()->prepare(
|
||||
"UPDATE lab_formularios SET esquema = ? WHERE id = 15"
|
||||
)->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
|
||||
|
||||
jsonOk(['exam_name' => $examName], "Examen '$examName' eliminado.");
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* /api/lab/tarifas_id.php
|
||||
*
|
||||
* GET → lista todas las tarifas (para selects en formularios)
|
||||
* POST {action:save, id?, nombre, porcentaje, tarifa_origen?} → upsert
|
||||
* POST {action:delete, id} → elimina si sin precios asociados
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$pdo = db();
|
||||
|
||||
if ($method === 'GET') {
|
||||
$rows = $pdo->query(
|
||||
"SELECT t.id, t.nombre, t.porcentaje, t.tarifa_origen,
|
||||
tb.nombre AS tarifa_origen_nombre
|
||||
FROM lab_tarifas_id t
|
||||
LEFT JOIN lab_tarifas_id tb ON tb.id = t.tarifa_origen
|
||||
ORDER BY t.id ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
jsonOk(['tarifas' => $rows]);
|
||||
}
|
||||
|
||||
requireMethod('POST');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$action = $data['action'] ?? '';
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if ($id <= 0) jsonError('id requerido');
|
||||
$chk = $pdo->prepare("SELECT COUNT(*) FROM lab_tarifas WHERE tarifa_id = ? LIMIT 1");
|
||||
$chk->execute([$id]);
|
||||
if ((int)$chk->fetchColumn() > 0) {
|
||||
jsonError('No se puede eliminar: la tarifa tiene precios de exámenes asociados.', 409);
|
||||
}
|
||||
$pdo->prepare("DELETE FROM lab_tarifas_id WHERE id = ?")->execute([$id]);
|
||||
jsonOk(['deleted' => true]);
|
||||
}
|
||||
|
||||
if ($action === 'save') {
|
||||
$id = isset($data['id']) && $data['id'] !== '' ? (int)$data['id'] : null;
|
||||
$nombre = trim($data['nombre'] ?? '');
|
||||
$porcentaje = isset($data['porcentaje']) ? (float)$data['porcentaje'] : 0;
|
||||
$origen = isset($data['tarifa_origen']) && $data['tarifa_origen'] !== '' ? (int)$data['tarifa_origen'] : null;
|
||||
|
||||
if ($nombre === '') jsonError('nombre requerido');
|
||||
|
||||
if ($id !== null) {
|
||||
$chk = $pdo->prepare("SELECT id FROM lab_tarifas_id WHERE id = ?");
|
||||
$chk->execute([$id]);
|
||||
if ($chk->fetch()) {
|
||||
$pdo->prepare(
|
||||
"UPDATE lab_tarifas_id SET nombre=?, porcentaje=?, tarifa_origen=? WHERE id=?"
|
||||
)->execute([$nombre, $porcentaje, $origen, $id]);
|
||||
} else {
|
||||
$pdo->prepare(
|
||||
"INSERT INTO lab_tarifas_id (id, nombre, porcentaje, tarifa_origen) VALUES (?,?,?,?)"
|
||||
)->execute([$id, $nombre, $porcentaje, $origen]);
|
||||
}
|
||||
} else {
|
||||
$maxId = (int)$pdo->query("SELECT COALESCE(MAX(id),0)+1 FROM lab_tarifas_id")->fetchColumn();
|
||||
$pdo->prepare(
|
||||
"INSERT INTO lab_tarifas_id (id, nombre, porcentaje, tarifa_origen) VALUES (?,?,?,?)"
|
||||
)->execute([$maxId, $nombre, $porcentaje, $origen]);
|
||||
$id = $maxId;
|
||||
}
|
||||
jsonOk(['id' => $id]);
|
||||
}
|
||||
|
||||
jsonError('Acción no reconocida', 400);
|
||||
@@ -17,7 +17,7 @@ $payload = json_encode([
|
||||
'generationConfig' => ['maxOutputTokens' => 10, 'temperature' => 0],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key={$key}");
|
||||
$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$key}");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
@@ -37,4 +37,4 @@ if ($code !== 200) {
|
||||
jsonError($gemini['error']['message'] ?? "Error HTTP {$code}", 502);
|
||||
}
|
||||
|
||||
jsonOk(['modelo' => 'gemini-3.5-flash', 'respuesta' => $gemini['candidates'][0]['content']['parts'][0]['text'] ?? '']);
|
||||
jsonOk(['modelo' => 'gemini-2.0-flash', 'respuesta' => $gemini['candidates'][0]['content']['parts'][0]['text'] ?? '']);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/qz_sign.php
|
||||
* Firma el request de QZ Tray con la llave privada RSA del servidor.
|
||||
* QZ Tray verifica la firma usando el digital-certificate.txt instalado en el cliente.
|
||||
*/
|
||||
header('Content-Type: text/plain');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
$request = file_get_contents('php://input');
|
||||
if (!$request) { http_response_code(400); exit('missing request'); }
|
||||
|
||||
$keyPath = __DIR__ . '/../config/qz/private-key.pem';
|
||||
$key = openssl_pkey_get_private('file://' . $keyPath);
|
||||
if (!$key) { http_response_code(500); exit('key error'); }
|
||||
|
||||
openssl_sign($request, $signature, $key, 'SHA512');
|
||||
echo base64_encode($signature);
|
||||
+6
-112
@@ -113,41 +113,24 @@ class WhatsAppWebhook {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extraer nombres de contactos del payload (contacts[].profile.name).
|
||||
// Se indexa por wa_id y también por user_id (BSUID): desde que Meta desplegó
|
||||
// los nombres de usuario, quien oculta su teléfono llega sin wa_id.
|
||||
// Extraer nombres de contactos del payload (contacts[].profile.name)
|
||||
$contactNames = [];
|
||||
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
||||
foreach ($value['contacts'] as $contact) {
|
||||
$waId = $contact['wa_id'] ?? null;
|
||||
$name = $contact['profile']['name'] ?? null;
|
||||
if (!$name) continue;
|
||||
foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
|
||||
if ($clave) $contactNames[$clave] = $name;
|
||||
if ($waId && $name) {
|
||||
$contactNames[$waId] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($items as $message) {
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos.
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos
|
||||
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
|
||||
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
|
||||
$timestamp = $message['timestamp'] ?? null;
|
||||
|
||||
// Meta manda el BSUID en todos los mensajes, traigan teléfono o no.
|
||||
$bsuid = $message['from_user_id'] ?? null;
|
||||
|
||||
// Si la persona oculta su teléfono, `from` y `wa_id` no llegan. En ese caso
|
||||
// se busca por BSUID: si ya escribió antes mostrando su número, se le
|
||||
// reconoce y se le sigue respondiendo a ese teléfono.
|
||||
if (empty($phoneNumber) && $bsuid) {
|
||||
$conocido = $this->db->fetch(
|
||||
"SELECT phone_number FROM users WHERE bsuid = :b", ['b' => $bsuid]
|
||||
);
|
||||
// Si no se le conoce, el BSUID hace de identificador: sirve para responderle,
|
||||
// aunque no permita cruzarlo con el paciente ni con el turnero.
|
||||
$phoneNumber = $conocido['phone_number'] ?? $bsuid;
|
||||
}
|
||||
|
||||
// Si falta lo crítico, saltar
|
||||
if (empty($phoneNumber) || empty($messageId)) {
|
||||
continue;
|
||||
@@ -165,7 +148,7 @@ class WhatsAppWebhook {
|
||||
|
||||
// Obtener o crear usuario
|
||||
$user = $this->getUserByPhone($phoneNumber);
|
||||
$contactName = $contactNames[$phoneNumber] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : null);
|
||||
$contactName = $contactNames[$phoneNumber] ?? null;
|
||||
if (!$user) {
|
||||
$userId = $this->createUser($phoneNumber);
|
||||
$user = $this->getUserById($userId);
|
||||
@@ -190,20 +173,6 @@ class WhatsAppWebhook {
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar la equivalencia BSUID↔usuario mientras la persona todavía muestra
|
||||
// su teléfono. El día que lo oculte, ese registro es lo único que permitirá
|
||||
// reconocerla, así que se anota en cada mensaje y no solo la primera vez.
|
||||
if ($bsuid && !empty($user['id']) && ($user['bsuid'] ?? null) !== $bsuid) {
|
||||
try {
|
||||
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$user['id']]);
|
||||
$user['bsuid'] = $bsuid;
|
||||
} catch (Exception $e) {
|
||||
// Choca si ese BSUID ya está en otro usuario (la persona cambió de
|
||||
// número). No es motivo para perder el mensaje: se sigue adelante.
|
||||
error_log('[webhook] No se pudo guardar el BSUID ' . $bsuid . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Procesar diferentes tipos de mensaje
|
||||
$messageText = '';
|
||||
$messageType = 'text';
|
||||
@@ -222,28 +191,6 @@ class WhatsAppWebhook {
|
||||
'reaction_emoji' => $emoji
|
||||
];
|
||||
|
||||
} elseif (($message['type'] ?? '') === 'contacts') {
|
||||
// La persona compartió su contacto, sea por el botón que se le pidió
|
||||
// o a mano. Es la única forma de obtener el teléfono de quien lo oculta.
|
||||
$messageType = 'contacts';
|
||||
$messageText = json_encode($message['contacts'] ?? []);
|
||||
|
||||
$telefonoCompartido = null;
|
||||
foreach ($message['contacts'] ?? [] as $c) {
|
||||
foreach ($c['phones'] ?? [] as $t) {
|
||||
// wa_id ya viene normalizado; `phone` puede traer espacios y signos
|
||||
$candidato = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||
if ($candidato) {
|
||||
$telefonoCompartido = preg_replace('/[^0-9]/', '', $candidato);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($telefonoCompartido && !empty($user['id'])) {
|
||||
$this->vincularTelefonoCompartido($user, $telefonoCompartido, $bsuid);
|
||||
}
|
||||
|
||||
} elseif (isset($message['interactive'])) {
|
||||
// Interactive replies (list or button) - normalize to text so bot can process
|
||||
$messageType = 'text';
|
||||
@@ -503,59 +450,6 @@ class WhatsAppWebhook {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vincula el teléfono que la persona acaba de compartir con el usuario que
|
||||
* hasta ahora solo se conocía por su BSUID.
|
||||
*
|
||||
* Puede haber dos registros de la misma persona: el viejo, de cuando escribía
|
||||
* mostrando el número, y el nuevo creado con el BSUID de identificador. No se
|
||||
* fusionan aquí (implicaría mover conversaciones, estados y aceptación de
|
||||
* términos, y una fusión mal hecha mezcla historias clínicas de dos personas):
|
||||
* se deja el registro con el teléfono como el bueno y se marca el otro, para
|
||||
* que alguien lo revise.
|
||||
*
|
||||
* @return bool si el teléfono quedó vinculado
|
||||
*/
|
||||
private function vincularTelefonoCompartido(&$user, $telefono, $bsuid) {
|
||||
// Ya lo teníamos: nada que hacer
|
||||
if (($user['phone_number'] ?? null) === $telefono) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$existente = $this->db->fetch(
|
||||
"SELECT id FROM users WHERE phone_number = :t AND id <> :id",
|
||||
['t' => $telefono, 'id' => $user['id']]
|
||||
);
|
||||
|
||||
try {
|
||||
if ($existente) {
|
||||
// El registro bueno es el que tiene el teléfono. Se le pasa el BSUID
|
||||
// para que a partir de ahora se le reconozca por ahí.
|
||||
if ($bsuid) {
|
||||
$this->db->update('users', ['bsuid' => null], 'id = ?', [$user['id']]);
|
||||
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$existente['id']]);
|
||||
}
|
||||
error_log(sprintf(
|
||||
'[webhook] BSUID %s compartió el teléfono %s, que ya era del usuario %d. ' .
|
||||
'El usuario %d queda duplicado y hay que revisarlo a mano.',
|
||||
$bsuid, $telefono, $existente['id'], $user['id']
|
||||
));
|
||||
$user = $this->getUserById($existente['id']) ?: $user;
|
||||
return true;
|
||||
}
|
||||
|
||||
// No había otro registro: el placeholder pasa a tener el teléfono real
|
||||
$this->db->update('users', ['phone_number' => $telefono], 'id = ?', [$user['id']]);
|
||||
$user['phone_number'] = $telefono;
|
||||
error_log(sprintf('[webhook] BSUID %s quedó vinculado al teléfono %s', $bsuid, $telefono));
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('[webhook] Error vinculando el teléfono compartido: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getUserByPhone($phoneNumber) {
|
||||
return $this->db->fetch(
|
||||
"SELECT * FROM users WHERE phone_number = :phone",
|
||||
|
||||
+116
-111
@@ -935,6 +935,32 @@ body {
|
||||
#sidebar-toggle { display: none !important; }
|
||||
}
|
||||
|
||||
/* Dark Mode Support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--light-bg: #1f2937;
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #d1d5db;
|
||||
--border-color: #374151;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--dark-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.text-center { text-align: center; }
|
||||
@@ -1457,125 +1483,104 @@ body {
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
MODO OSCURO
|
||||
Activación dual:
|
||||
1) @media (prefers-color-scheme: dark) — OS sin JS
|
||||
2) [data-bs-theme="dark"] en <html> — Bootstrap 5.3 via script inline
|
||||
|
||||
Bootstrap 5.3 maneja automáticamente (cuando data-bs-theme está activo):
|
||||
card, modal, table, form-control, form-select, dropdown, badge, alert,
|
||||
list-group, nav, pagination, offcanvas, toast, popover, etc.
|
||||
Aquí solo cubrimos variables custom y elementos no-Bootstrap.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Variables custom para elementos no-Bootstrap ── */
|
||||
/* ===== Ajustes para modo nocturno (dark mode) ===== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--dm-body: #0a1628;
|
||||
--dm-surf: #071322;
|
||||
--dm-surf2: #0b1c32;
|
||||
--dm-bdr: #163240;
|
||||
--dm-txt: #e6eef1;
|
||||
--dm-muted: #9ca3af;
|
||||
--dm-link: #a6f3c9;
|
||||
/* Alias compatibilidad con código que usa estas vars */
|
||||
--light-bg: #071322;
|
||||
--text-primary: #e6eef1;
|
||||
--text-secondary: #9ca3af;
|
||||
--border-color: #163240;
|
||||
}
|
||||
}
|
||||
[data-bs-theme="dark"] {
|
||||
--dm-body: #0a1628;
|
||||
--dm-surf: #071322;
|
||||
--dm-surf2: #0b1c32;
|
||||
--dm-bdr: #163240;
|
||||
--dm-txt: #e6eef1;
|
||||
--dm-muted: #9ca3af;
|
||||
--dm-link: #a6f3c9;
|
||||
--light-bg: #071322;
|
||||
--text-primary: #e6eef1;
|
||||
--text-secondary: #9ca3af;
|
||||
--border-color: #163240;
|
||||
}
|
||||
|
||||
/* ── Reglas para modo OS sin JS (fallback) ── */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background-color: var(--dm-body); color: var(--dm-txt); }
|
||||
|
||||
/* Fondo principal */
|
||||
.main-content { background-color: var(--dm-body) !important; }
|
||||
|
||||
/* Bootstrap no cambia bg-white — forzamos adaptación */
|
||||
.bg-white { background-color: var(--dm-surf) !important; color: var(--dm-txt) !important; }
|
||||
|
||||
/* Encabezados y texto */
|
||||
.content-header { background: var(--dm-surf); border-color: var(--dm-bdr) !important; color: var(--dm-txt); }
|
||||
.content-header h1, .card-header h5, .sidebar-header h4,
|
||||
.status-text, .status-indicator, .conversation-empty { color: var(--dm-txt) !important; }
|
||||
|
||||
/* Componentes Bootstrap (fallback sin data-bs-theme) */
|
||||
.card { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); box-shadow: none; }
|
||||
.card-header { background: transparent; border-color: rgba(255,255,255,.06); }
|
||||
.form-control, .form-select {
|
||||
background: var(--dm-surf2);
|
||||
color: var(--dm-txt);
|
||||
border-color: var(--dm-bdr);
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
background: var(--dm-surf2);
|
||||
color: var(--dm-txt);
|
||||
border-color: #2d6a8a;
|
||||
box-shadow: 0 0 0 .2rem rgba(45,106,138,.25);
|
||||
}
|
||||
.table { color: var(--dm-txt); border-color: var(--dm-bdr); }
|
||||
.table th { background: var(--dm-surf2); color: var(--dm-txt); }
|
||||
.modal-content { background: var(--dm-surf); color: var(--dm-txt); }
|
||||
.modal-header, .modal-footer { border-color: var(--dm-bdr); }
|
||||
.dropdown-menu { background: var(--dm-surf); border-color: var(--dm-bdr); }
|
||||
.dropdown-item { color: var(--dm-txt); }
|
||||
.dropdown-item:hover { background: var(--dm-surf2); color: var(--dm-txt); }
|
||||
.list-group-item { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); }
|
||||
.input-group-text { background: var(--dm-surf2); color: var(--dm-txt); border-color: var(--dm-bdr); }
|
||||
.nav-tabs .nav-link { color: var(--dm-muted); }
|
||||
.nav-tabs .nav-link.active { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); }
|
||||
|
||||
/* Sidebar custom */
|
||||
.sidebar { background: linear-gradient(135deg, #08131a, #0b2221); color: #e6eef1; }
|
||||
|
||||
/* Chat */
|
||||
.chat-container { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); }
|
||||
.chat-header { background: linear-gradient(135deg, #1f5a3f, #0f513e); color: #fff; }
|
||||
.message-bubble { color: var(--dm-txt); }
|
||||
.message-incoming { background: var(--dm-surf2); color: var(--dm-txt); box-shadow: none; }
|
||||
.message-outgoing { background: #0f513e; color: #fff; }
|
||||
.message-document, .media-preview { background: rgba(255,255,255,.03); color: var(--dm-txt); }
|
||||
.conversation-search { background: rgba(255,255,255,.04); color: var(--dm-txt); }
|
||||
.conversation-actions .btn { background: #0b1220; border-color: #1f2937; color: var(--dm-muted); }
|
||||
|
||||
/* Links */
|
||||
a:not(.btn):not(.badge), .nav-link { color: var(--dm-link); }
|
||||
|
||||
/* Placeholders */
|
||||
::placeholder { color: var(--dm-muted) !important; opacity: 1; }
|
||||
body {
|
||||
background-color: var(--light-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Reglas para Bootstrap dark mode activo (data-bs-theme="dark") ── */
|
||||
/* Bootstrap ya maneja card, modal, table, form, dropdown, badge, alert, etc. */
|
||||
/* Solo corregimos lo que Bootstrap no toca: elementos custom y clases forzadas */
|
||||
/* Encabezados y títulos */
|
||||
.content-header h1,
|
||||
.card-header h5,
|
||||
.sidebar-header h4,
|
||||
.status-text,
|
||||
.status-indicator,
|
||||
.conversation-empty {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .main-content { background-color: var(--dm-body, #0a1628) !important; }
|
||||
[data-bs-theme="dark"] .bg-white { background-color: var(--bs-body-bg) !important; color: var(--bs-body-color) !important; }
|
||||
[data-bs-theme="dark"] .sidebar { background: linear-gradient(135deg, #08131a, #0b2221) !important; color: #e6eef1; }
|
||||
[data-bs-theme="dark"] .chat-container { background: var(--dm-surf, #071322); border-color: var(--dm-bdr, #163240); }
|
||||
[data-bs-theme="dark"] .chat-header { background: linear-gradient(135deg, #1f5a3f, #0f513e); color: #fff; }
|
||||
[data-bs-theme="dark"] .message-bubble { color: var(--dm-txt, #e6eef1); }
|
||||
[data-bs-theme="dark"] .message-incoming { background: var(--dm-surf2, #0b1c32); color: var(--dm-txt, #e6eef1); box-shadow: none; }
|
||||
[data-bs-theme="dark"] .message-outgoing { background: #0f513e; color: #fff; }
|
||||
[data-bs-theme="dark"] .message-document,
|
||||
[data-bs-theme="dark"] .media-preview { background: rgba(255,255,255,.03); color: var(--dm-txt, #e6eef1); }
|
||||
[data-bs-theme="dark"] .conversation-search { background: rgba(255,255,255,.04); color: var(--dm-txt, #e6eef1); }
|
||||
[data-bs-theme="dark"] .conversation-actions .btn { background: #0b1220; border-color: #1f2937; color: var(--dm-muted, #9ca3af); }
|
||||
[data-bs-theme="dark"] a:not(.btn):not(.badge) { color: var(--dm-link, #a6f3c9); }
|
||||
[data-bs-theme="dark"] ::placeholder { color: var(--dm-muted, #9ca3af) !important; opacity: 1; }
|
||||
/* Contenedores */
|
||||
.content-header,
|
||||
.card,
|
||||
.chat-container {
|
||||
background: #071322;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: transparent;
|
||||
border-bottom-color: rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
/* Formularios y botones */
|
||||
.form-control {
|
||||
background: #072033;
|
||||
color: var(--text-primary);
|
||||
border-color: #163240;
|
||||
}
|
||||
|
||||
.btn {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
background: linear-gradient(135deg,#08131a,#0b2221);
|
||||
color: #e6eef1;
|
||||
}
|
||||
|
||||
.conversation-actions .btn {
|
||||
background: #0b1220;
|
||||
border-color: #1f2937;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Chat específico */
|
||||
.chat-header {
|
||||
background: linear-gradient(135deg, #1f5a3f 0%, #0f513e 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
color: #e6eef1;
|
||||
}
|
||||
|
||||
.message-incoming {
|
||||
background: #0b1220;
|
||||
color: #e6eef1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.message-outgoing {
|
||||
background: #0f513e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-document,
|
||||
.media-preview {
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.conversation-search {
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
a, .nav-link {
|
||||
color: #a6f3c9;
|
||||
}
|
||||
|
||||
/* Placeholders visibles */
|
||||
::placeholder { color: #9ca3af !important; opacity: 1; }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* qz-print.js — Impresión silenciosa vía QZ Tray
|
||||
* Requiere qz-tray.js cargado antes de este archivo.
|
||||
* Uso: qzPrint({ url, printer })
|
||||
*/
|
||||
window.qzPrint = async function({ printer } = {}) {
|
||||
try {
|
||||
// 1. Conectar
|
||||
if (!qz.websocket.isActive()) {
|
||||
await qz.websocket.connect();
|
||||
}
|
||||
|
||||
// 2. Firma con el servidor
|
||||
qz.security.setSignatureAlgorithm('SHA512');
|
||||
qz.security.setSignaturePromise(function(toSign) {
|
||||
return function(resolve, reject) {
|
||||
fetch('/api/qz_sign.php', { method: 'POST', body: toSign })
|
||||
.then(r => r.text()).then(resolve).catch(reject);
|
||||
};
|
||||
});
|
||||
|
||||
// 3. Certificado público
|
||||
qz.security.setCertificatePromise(function(resolve) {
|
||||
fetch('/config/qz/digital-certificate.txt')
|
||||
.then(r => r.text()).then(resolve);
|
||||
});
|
||||
|
||||
// 4. Seleccionar impresora
|
||||
var p = printer || await qz.printers.getDefault();
|
||||
console.log('[QZ] Impresora:', p);
|
||||
|
||||
// 5. Imprimir la página actual como HTML
|
||||
var cfg = qz.configs.create(p);
|
||||
var data = [{
|
||||
type : 'pixel',
|
||||
format: 'html',
|
||||
flavor: 'plain',
|
||||
data : document.documentElement.outerHTML,
|
||||
}];
|
||||
|
||||
await qz.print(cfg, data);
|
||||
console.log('[QZ] Trabajo enviado correctamente');
|
||||
} catch(e) {
|
||||
console.warn('[QZ] Error:', e.message || e);
|
||||
// Fallback al diálogo del navegador si QZ no está disponible
|
||||
window.print();
|
||||
}
|
||||
};
|
||||
@@ -364,7 +364,7 @@ class Domicilio {
|
||||
|
||||
private function filtrarCampos(array $datos): array {
|
||||
$permitidos = [
|
||||
'orden_id', 'paciente_id', 'numero_orden', 'direccion', 'ciudad', 'barrio',
|
||||
'orden_id', 'paciente_id', 'direccion', 'ciudad', 'barrio',
|
||||
'indicaciones_dir', 'fecha_programada', 'hora_programada',
|
||||
'tipo_servicio', 'tipo_cliente', 'examenes_solicitados',
|
||||
'estado', 'motivo_cancelacion',
|
||||
|
||||
@@ -23,10 +23,8 @@ class Formulario {
|
||||
$w = $soloActivos ? 'WHERE f.is_active = 1' : '';
|
||||
return $this->db->fetchAll("
|
||||
SELECT f.*, u.full_name AS creado_por_nombre,
|
||||
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) +
|
||||
(SELECT COUNT(*) FROM turnero_consentimientos tc WHERE tc.formulario_id = f.id) AS total_envios,
|
||||
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id AND e.estado IN ('completado','firmado')) +
|
||||
(SELECT COUNT(*) FROM turnero_consentimientos tc WHERE tc.formulario_id = f.id AND tc.estado = 'firmado') AS total_completados
|
||||
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) AS total_envios,
|
||||
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id AND e.estado IN ('completado','firmado')) AS total_completados
|
||||
FROM lab_formularios f
|
||||
LEFT JOIN admin_users u ON u.id = f.creado_por
|
||||
$w
|
||||
@@ -216,13 +214,13 @@ class Formulario {
|
||||
$whereB[] = '1=0'; // estados como 'completado'/'expirado' no aplican al turnero
|
||||
}
|
||||
}
|
||||
if (!empty($filtros['fecha_desde'])) { $whereB[] = 'DATE(COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at)) >= ?'; $paramsB[] = $filtros['fecha_desde']; }
|
||||
if (!empty($filtros['fecha_hasta'])) { $whereB[] = 'DATE(COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at)) <= ?'; $paramsB[] = $filtros['fecha_hasta']; }
|
||||
if (!empty($filtros['fecha_desde'])) { $whereB[] = 'DATE(tc.enviado_at) >= ?'; $paramsB[] = $filtros['fecha_desde']; }
|
||||
if (!empty($filtros['fecha_hasta'])) { $whereB[] = 'DATE(tc.enviado_at) <= ?'; $paramsB[] = $filtros['fecha_hasta']; }
|
||||
if (!empty($filtros['paciente'])) { $whereB[] = 'p.nombre_completo LIKE ?'; $paramsB[] = '%' . $filtros['paciente'] . '%'; }
|
||||
if (!empty($filtros['enviado_por'])) { $whereB[] = '1=0'; } // turnero no tiene enviado_por
|
||||
$wB = implode(' AND ', $whereB);
|
||||
|
||||
$sqlB = "SELECT tc.id, tc.formulario_id, tc.estado, COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at) AS fecha,
|
||||
$sqlB = "SELECT tc.id, tc.formulario_id, tc.estado, COALESCE(tc.enviado_at, tc.firmado_at) AS fecha,
|
||||
f.nombre AS form_nombre, f.categoria,
|
||||
p.nombre_completo AS paciente_nombre,
|
||||
'Turnero' AS enviado_por_nombre,
|
||||
|
||||
+15
-65
@@ -7,15 +7,6 @@
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
// esBsuid() vive en config.php; se garantiza aquí por si esta clase se incluye
|
||||
// directamente, sin pasar por el arranque del ERP.
|
||||
if (!function_exists('esBsuid')) {
|
||||
$configPaciente = __DIR__ . '/../../config/config.php';
|
||||
if (file_exists($configPaciente)) {
|
||||
require_once $configPaciente;
|
||||
}
|
||||
}
|
||||
|
||||
class Paciente {
|
||||
|
||||
private Database $db;
|
||||
@@ -36,25 +27,20 @@ class Paciente {
|
||||
public function listar(
|
||||
string $busqueda = '',
|
||||
int $pagina = 1,
|
||||
int $porPagina = 30,
|
||||
string $origen = ''
|
||||
int $porPagina = 30
|
||||
): array {
|
||||
$offset = ($pagina - 1) * $porPagina;
|
||||
$like = "%$busqueda%";
|
||||
$params = $busqueda
|
||||
? [$like, $like, $like, $like]
|
||||
: [];
|
||||
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
if ($busqueda) {
|
||||
$conditions[] = "(p.nombre_completo LIKE ? OR p.numero_documento LIKE ? OR p.telefono LIKE ? OR p.email LIKE ?)";
|
||||
$params = [$like, $like, $like, $like];
|
||||
}
|
||||
if ($origen) {
|
||||
$conditions[] = "p.origen = ?";
|
||||
$params[] = $origen;
|
||||
}
|
||||
|
||||
$where = $conditions ? 'WHERE ' . implode(' AND ', $conditions) : '';
|
||||
$where = $busqueda
|
||||
? "WHERE p.nombre_completo LIKE ?
|
||||
OR p.numero_documento LIKE ?
|
||||
OR p.telefono LIKE ?
|
||||
OR p.email LIKE ?"
|
||||
: '';
|
||||
|
||||
$total = $this->db->fetch(
|
||||
"SELECT COUNT(*) AS n FROM lab_pacientes p $where",
|
||||
@@ -70,7 +56,7 @@ class Paciente {
|
||||
FROM lab_pacientes p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
$where
|
||||
ORDER BY p.created_at DESC, p.nombre_completo ASC
|
||||
ORDER BY p.nombre_completo ASC
|
||||
LIMIT ? OFFSET ?
|
||||
", array_merge($params, [$porPagina, $offset]));
|
||||
|
||||
@@ -83,19 +69,6 @@ class Paciente {
|
||||
];
|
||||
}
|
||||
|
||||
public function stats(): array {
|
||||
return $this->db->fetch("
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(origen = 'lab') AS importados,
|
||||
SUM(origen = 'manual') AS manuales,
|
||||
SUM(origen = 'whatsapp') AS whatsapp,
|
||||
SUM(user_id IS NOT NULL) AS con_wa
|
||||
FROM lab_pacientes
|
||||
WHERE is_active = 1
|
||||
") ?: ['total' => 0, 'importados' => 0, 'manuales' => 0, 'whatsapp' => 0, 'con_wa' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Un paciente por ID (con datos del usuario WhatsApp).
|
||||
*/
|
||||
@@ -182,22 +155,8 @@ class Paciente {
|
||||
}
|
||||
}
|
||||
|
||||
// Registrar antes y después de lo que realmente cambia. Guardar solo el
|
||||
// valor nuevo impide reconstruir el dato anterior si la corrección
|
||||
// resultó equivocada, que es justo cuando hace falta consultarlo.
|
||||
$antes = $this->db->fetch('SELECT * FROM lab_pacientes WHERE id = ?', [$id]) ?: [];
|
||||
$cambios = [];
|
||||
foreach ($campos as $campo => $nuevo) {
|
||||
$previo = $antes[$campo] ?? null;
|
||||
if ((string)$previo !== (string)$nuevo) {
|
||||
$cambios[$campo] = ['antes' => $previo, 'despues' => $nuevo];
|
||||
}
|
||||
}
|
||||
|
||||
$ok = $this->db->update('lab_pacientes', $campos, 'id = ?', [$id]);
|
||||
if ($cambios) {
|
||||
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $cambios);
|
||||
}
|
||||
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $campos);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
@@ -267,19 +226,10 @@ class Paciente {
|
||||
[$userId]
|
||||
);
|
||||
|
||||
// Quien oculta su teléfono en WhatsApp se identifica con un BSUID, que ocupa
|
||||
// el lugar del número en `users`. No es un teléfono: guardarlo aquí dejaría
|
||||
// en la historia clínica un dato falso con apariencia de número real, porque
|
||||
// normalizarTelefono() le quita el punto y las letras y lo deja en 16 dígitos.
|
||||
// Mejor la ficha sin teléfono, que es la verdad: no lo tenemos.
|
||||
$identificador = $user['phone_number'] ?? null;
|
||||
$esIdentificadorSinTelefono = esBsuid($identificador);
|
||||
|
||||
return $this->crear([
|
||||
'user_id' => $userId,
|
||||
'nombre_completo'=> $user['name']
|
||||
?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
|
||||
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
|
||||
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
|
||||
'telefono' => $user['phone_number'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -288,7 +238,7 @@ class Paciente {
|
||||
'user_id', 'numero_documento', 'tipo_documento',
|
||||
'nombre_completo', 'telefono', 'email',
|
||||
'fecha_nacimiento', 'genero', 'direccion',
|
||||
'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active', 'origen',
|
||||
'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active',
|
||||
];
|
||||
$campos = array_intersect_key($datos, array_flip($permitidos));
|
||||
if (!empty($campos['telefono'])) {
|
||||
|
||||
@@ -62,7 +62,6 @@ define('SYSTEM_MODULES', [
|
||||
// ── Sistema ──────────────────────────────────────────────────────────────
|
||||
'usuarios' => 'Gestión de Usuarios',
|
||||
'enfermero_portal' => 'Portal Enfermero',
|
||||
'soporte' => 'Soporte y Documentación',
|
||||
// ── Oleada 1 — Turnero ───────────────────────────────────────────────────
|
||||
'turnero' => 'Turnero',
|
||||
// ── Oleada 2 — pendiente ─────────────────────────────────────────────────
|
||||
@@ -339,24 +338,6 @@ function deleteConfigFromDB($key) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('esBsuid')) {
|
||||
/**
|
||||
* ¿Este identificador es un BSUID de Meta y no un número de teléfono?
|
||||
*
|
||||
* Desde que WhatsApp permite ocultar el número, quien lo oculta llega
|
||||
* identificado solo por su BSUID, con la forma "CO.1761088155094242".
|
||||
* Ese valor ocupa el lugar del teléfono dentro del bot, así que hay que
|
||||
* distinguirlo antes de tratarlo como si fuera un número real: guardarlo
|
||||
* en un campo de teléfono deja un dato falso con toda la pinta de verdadero.
|
||||
*
|
||||
* @param mixed $valor
|
||||
* @return bool
|
||||
*/
|
||||
function esBsuid($valor) {
|
||||
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $valor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el cache estático de configuraciones
|
||||
*/
|
||||
@@ -421,14 +402,6 @@ if (!defined('BASE_URL')) {
|
||||
if (!defined('MIGRATION_TOKEN')) {
|
||||
define('MIGRATION_TOKEN', 'lab2026migrate');
|
||||
}
|
||||
// API key server-to-server para ingesta de pacientes desde RIPS Manager
|
||||
if (!defined('LAB_SYNC_KEY')) {
|
||||
define('LAB_SYNC_KEY', getenv('LAB_SYNC_KEY') ?: 'rips-lab-sync-2026');
|
||||
}
|
||||
// URL base del RIPS Manager (para consultas server-to-server)
|
||||
if (!defined('RIPS_MANAGER_URL')) {
|
||||
define('RIPS_MANAGER_URL', getenv('RIPS_MANAGER_URL') ?: '');
|
||||
}
|
||||
define('TIMEZONE', 'America/Bogota');
|
||||
|
||||
// Información del desarrollador
|
||||
@@ -571,7 +544,6 @@ function authenticateUser($username, $password) {
|
||||
'role_id' => $admin['role_id'] ?? null,
|
||||
'home_page' => $homePage,
|
||||
'enfermera_id' => $admin['enfermera_id'] ?? null,
|
||||
'turnero_lugar_id' => $admin['turnero_lugar_id'] ?? null,
|
||||
'modules' => $modules,
|
||||
'module_permissions' => $modulePermissions,
|
||||
];
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Bloquear acceso web a la llave privada
|
||||
<Files "private-key.pem">
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</Files>
|
||||
@@ -1,24 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIID+TCCAuGgAwIBAgIULkHXGPyy0w0ieUoN/YOZOO5MPvkwDQYJKoZIhvcNAQEL
|
||||
BQAwgYsxCzAJBgNVBAYTAkNPMRswGQYDVQQIDBJOb3J0ZSBkZSBTYW50YW5kZXIx
|
||||
DzANBgNVBAcMBkN1Y3V0YTEjMCEGA1UECgwaTGFib3JhdG9yaW8gWGltZW5hIENh
|
||||
aWNlZG8xKTAnBgNVBAMMIGVycC5sYWJvcmF0b3Jpb3hpbWVuYWNhaWNlZG8uY29t
|
||||
MB4XDTI2MDcyMTE5NTEzNloXDTM2MDcxODE5NTEzNlowgYsxCzAJBgNVBAYTAkNP
|
||||
MRswGQYDVQQIDBJOb3J0ZSBkZSBTYW50YW5kZXIxDzANBgNVBAcMBkN1Y3V0YTEj
|
||||
MCEGA1UECgwaTGFib3JhdG9yaW8gWGltZW5hIENhaWNlZG8xKTAnBgNVBAMMIGVy
|
||||
cC5sYWJvcmF0b3Jpb3hpbWVuYWNhaWNlZG8uY29tMIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEAjbzIjSUEj6qjwHvg+qNAEk0rAjfezbpyGPGkbH1AFnmI
|
||||
8rz2TZKAN5PWJSvjYS91YEtnfFttEDa0wQpvHOIPhn2l8gAsYNFoDS+i1ilXHBEA
|
||||
OtAB7uVL2TvrZBTQtcab4upI1ehUjdqA9ae51KBEfqOD3k7dc5X8IeskBDMLfI5G
|
||||
cuJejGpx6S8aFur/PfZfu3oApHW02b4GXqSguUHwBJZtGauFQWRrN/6VG3YVKXON
|
||||
g8bDEyFgPCZ2Slp4pHoLK5BIjBkF1t3PUcSq5rRZCxjYzoOcDIfoVV5f4Y+QSspM
|
||||
u3tI+cMAnqwrKma/TBmSSikTMuK9nb+qjVvlQ/kijwIDAQABo1MwUTAdBgNVHQ4E
|
||||
FgQUVnCTxhnONKLUYtxlT4SsIPH5rEgwHwYDVR0jBBgwFoAUVnCTxhnONKLUYtxl
|
||||
T4SsIPH5rEgwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAEL/L
|
||||
PP8b2UQavVLh3r0D3Is0V7JrVTRsT/iB2FwJLhudHAojVBinh7+tvZboEFkJ5trR
|
||||
bNG3XxXDprw76awc/BsR19YXWrjZfraROrVNYEVTW1+Mit2HYRuTiEnVxOX1EENR
|
||||
dAwZrwtFmpy3I2sZNblxQm0cZbDq6g4x/LBTwUXmXQK259Nrv5SEjs8wixeDMDNC
|
||||
wuA9vAamsw+38nqHCouNtE+yPwM7srinFkfilMnIB2d874eqaVBvEKWmUtBCsWaO
|
||||
f5OviyodKH9BtF+ACpgU2E/at03VratR5REIjje52qsWOMY1Wnn8FIYsCWJIPQYu
|
||||
m56yKz4rQLPX/kyeCQ==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCNvMiNJQSPqqPA
|
||||
e+D6o0ASTSsCN97NunIY8aRsfUAWeYjyvPZNkoA3k9YlK+NhL3VgS2d8W20QNrTB
|
||||
Cm8c4g+GfaXyACxg0WgNL6LWKVccEQA60AHu5UvZO+tkFNC1xpvi6kjV6FSN2oD1
|
||||
p7nUoER+o4PeTt1zlfwh6yQEMwt8jkZy4l6ManHpLxoW6v899l+7egCkdbTZvgZe
|
||||
pKC5QfAElm0Zq4VBZGs3/pUbdhUpc42DxsMTIWA8JnZKWnikegsrkEiMGQXW3c9R
|
||||
xKrmtFkLGNjOg5wMh+hVXl/hj5BKyky7e0j5wwCerCsqZr9MGZJKKRMy4r2dv6qN
|
||||
W+VD+SKPAgMBAAECggEAPqedtfsPrZx+f6ejN9hzicOQCA5/kMzjBBDJoOWrL2Qx
|
||||
PDB45qikwiy5ZLwmav8qMVOT3v6hUyIDvDPrE0cBGvAvK6+U7oWTLAULRAWJStBf
|
||||
HCB4Qk0dPt3Ee/zRmBFANspfQSPPQNe+2xj2Rj5EmQCaWerd7Or3xlymEq8n3Dp2
|
||||
E9pFn4wiEDdjXG69/fqu/LoFpz31E2Mp0q+V4ZdWvMXA4U6akH2/oGWCnjDdhm/O
|
||||
Wy1shQL5oVxdw/yhHhaC/3GZv/MKVbaW7JeBtnt3S5Szktr6A1O2obSJS+KOw9d+
|
||||
Y2rqIMAWXv+kHKzAB2LtfczpfkEDkqQ7jCbu+sjX4QKBgQC+3uwfwM9mlxoCt93E
|
||||
cerMyfrbssTjZjZSsxnHsGz+qmDr8TIyOrwcZnnYs6MNkuTJW6MvgN8hM8oO2arh
|
||||
E3s0oWrbILD42rzVQUeLr7/N/JjUwK7yyEnfZX/hnQdilRsT+rxpXBIA0HfUheih
|
||||
6KkddNoOyvs+wu1eOralO117LwKBgQC+Ge4ch1hicRchwX/Bu+h3cdkLVVgdI08+
|
||||
O7vnSCuhj8Ead7z3C5XjEqJOs75xY+LF4UxPsrqTuCxpWIOupCSnRe+RGMcJdzbR
|
||||
0i/leyL9fWhvHzP13R4T6sqAHqIXKgCfR7Hx99TW3Whc4N2GoqAnIrjqow+H+ic8
|
||||
8Hrr+Ul2oQKBgQC1AyRbWLdYS6RXP5gJXR+X51UIVZlzLtQFyeSBBEfZnCselzdL
|
||||
e3g6VtTnNjVEAjMG4uj3e/gfvMW7H6J2ocsONqbn+TDcUFUUyTvYtWvpJcyqt7Ey
|
||||
fc/RFKkahZkjXNS5NejI4pAQRaPe4L+mDMeVL+Q8czOiaapC2tusB4i38QKBgCYh
|
||||
G1Jbj03HcyVRI2ffYcQ7cJZGWvMVNvq7jnfYUPAJ3miJpbxDdZ/jB+0TPlqN91lL
|
||||
VDwUFDo20ambmGX6BGQMsf1/Y8SxRayWJQc5SI5hjgXj0084N6U1DcLe4hIVWaSZ
|
||||
A8cNt4IVTK58Z9JuYgMXgtGFPUM/2Ijvjygvix2hAoGBALSgjUzho5Jn/vAQza/M
|
||||
k4q24RZ0kBJ1Go+/b3iSOieCCVKgow9224r/frcrFvXWzbNjhKs/HAV1bB9uCrxD
|
||||
/V/1byH0XLYmYIKvhTqpTtBLCYXrv+2m+ox8pHrgPze4KuJy3VdN/V/Dj3CpY0VL
|
||||
G5Uf9TfOiMk4EiZw/pBR1YT9
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -44,30 +44,6 @@ class App
|
||||
header('Location: ' . APP_ROOT . '/../enfermero_portal.php');
|
||||
exit;
|
||||
}
|
||||
// Recepcionistas con IP registrada: solo pueden acceder a su desk
|
||||
if (Auth::role() === 'recepcionista') {
|
||||
$mod = $router->getModule();
|
||||
$view = $router->getView();
|
||||
if ($mod === 'turnero' && $view === 'recepcion') {
|
||||
$deskId = (int)($_GET['desk_id'] ?? 0);
|
||||
$assignedDesk = self::recepIpDesk();
|
||||
if ($assignedDesk && $deskId !== $assignedDesk) {
|
||||
header('Location: /erp.php?m=turnero&v=recepcion&desk_id=' . $assignedDesk);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bacteriólogos: sin dashboard ni historial; redirige según IP
|
||||
if (Auth::isBacteriologo()) {
|
||||
$mod = $router->getModule();
|
||||
$view = $router->getView();
|
||||
$blocked = ($mod === 'dashboard')
|
||||
|| ($mod === 'turnero' && in_array($view, ['dashboard', 'historial'], true));
|
||||
if ($blocked) {
|
||||
header('Location: ' . self::bacteDefaultUrl());
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::dispatch($router);
|
||||
@@ -123,72 +99,6 @@ class App
|
||||
include $viewFile;
|
||||
}
|
||||
|
||||
// ─── Helpers de rol ─────────────────────────────────────────────────────
|
||||
|
||||
/** IP real del cliente, soporta proxy con X-Forwarded-For. */
|
||||
public static function clientIp(): string
|
||||
{
|
||||
$raw = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
return trim(explode(',', $raw)[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve el lugar_id de recepción asignado a la IP del cliente, o null si no está registrada.
|
||||
*/
|
||||
private static function recepIpDesk(): ?int
|
||||
{
|
||||
try {
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$st = $pdo->prepare(
|
||||
"SELECT d.lugar_id FROM turnero_dispositivos d
|
||||
JOIN turnero_lugares l ON l.id = d.lugar_id
|
||||
WHERE d.ip = ? AND d.activo = 1 AND l.tipo = 'recepcion' LIMIT 1"
|
||||
);
|
||||
$st->execute([self::clientIp()]);
|
||||
$id = $st->fetchColumn();
|
||||
return $id ? (int)$id : null;
|
||||
} catch (\Throwable $_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL de destino para bacteriólogo según IP del cliente.
|
||||
* IP registrada en turnero_dispositivos → ese lugar.
|
||||
* IP no registrada → primer lugar de tipo muestras.
|
||||
*/
|
||||
private static function bacteDefaultUrl(): string
|
||||
{
|
||||
$base = '/erp.php?m=turnero&v=lugar&lugar_id=';
|
||||
try {
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
// 1. Por token de navegador
|
||||
$tok = trim($_COOKIE['turnero_token'] ?? '');
|
||||
if ($tok) {
|
||||
$s = $pdo->prepare(
|
||||
"SELECT lugar_id FROM turnero_dispositivos WHERE token = ? AND activo = 1 LIMIT 1"
|
||||
);
|
||||
$s->execute([$tok]);
|
||||
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) return $base . (int)$row['lugar_id'];
|
||||
}
|
||||
// 2. Por IP
|
||||
$ip = self::clientIp();
|
||||
$s = $pdo->prepare(
|
||||
"SELECT lugar_id FROM turnero_dispositivos WHERE ip = ? AND token IS NULL AND activo = 1 LIMIT 1"
|
||||
);
|
||||
$s->execute([$ip]);
|
||||
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) return $base . (int)$row['lugar_id'];
|
||||
// 3. Primer lugar de toma de muestras
|
||||
$first = $pdo->query(
|
||||
"SELECT id FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order LIMIT 1"
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
if ($first) return $base . (int)$first['id'];
|
||||
} catch (\Throwable $_) {}
|
||||
return '/erp.php?m=turnero';
|
||||
}
|
||||
|
||||
// ─── Páginas de error ────────────────────────────────────────────────────
|
||||
|
||||
private static function render404(string $module, string $view): void
|
||||
|
||||
@@ -67,11 +67,6 @@ class Auth
|
||||
return self::role() === 'enfermero';
|
||||
}
|
||||
|
||||
public static function isBacteriologo(): bool
|
||||
{
|
||||
return self::role() === 'bacteriologo';
|
||||
}
|
||||
|
||||
public static function isAdmin(): bool
|
||||
{
|
||||
return self::role() === 'admin';
|
||||
|
||||
+3
-6
@@ -60,15 +60,12 @@ class Layout
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $safeTitle ?> — <?= htmlspecialchars($appName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></title>
|
||||
|
||||
<!-- Dark mode: aplica data-bs-theme antes del primer paint para evitar flash -->
|
||||
<script>(function(){try{var m=window.matchMedia('(prefers-color-scheme: dark)');function a(d){document.documentElement.setAttribute('data-bs-theme',d?'dark':'light');}a(m.matches);m.addEventListener('change',function(e){a(e.matches);});}catch(e){}})();</script>
|
||||
|
||||
<!-- Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome 6 -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<!-- Estilos del sistema -->
|
||||
<link href="<?= $base ?>/assets/css/styles.css?v=15" rel="stylesheet">
|
||||
<link href="<?= $base ?>/assets/css/styles.css?v=14" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
@@ -80,7 +77,7 @@ class Layout
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
background: var(--bs-body-bg, #f8f9fa);
|
||||
background: #f8f9fa;
|
||||
}
|
||||
@media (max-width: 991.98px) {
|
||||
.main-content { margin-left: 0; }
|
||||
@@ -111,7 +108,7 @@ class Layout
|
||||
<!-- Contenido principal -->
|
||||
<main class="main-content">
|
||||
<!-- Barra de título de módulo -->
|
||||
<div class="bg-body-tertiary border-bottom px-4 py-3 d-flex align-items-center justify-content-between">
|
||||
<div class="bg-white border-bottom px-4 py-3 d-flex align-items-center justify-content-between">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="<?= $safeIcon ?> me-2 text-primary"></i><?= $safeTitle ?>
|
||||
</h5>
|
||||
|
||||
@@ -23,14 +23,6 @@ class Router
|
||||
private const PUBLIC_ROUTES = [
|
||||
'turnero/display',
|
||||
'turnero/kiosko',
|
||||
// Tablet de firma del paciente: la manipula el público y nadie va a
|
||||
// iniciar sesión en ella cada mañana. No queda abierta: se identifica
|
||||
// por la cookie del dispositivo y sin ella no muestra dato alguno.
|
||||
'turnero/firma',
|
||||
// Prueba de voces: hay que abrirla EN el televisor para saber qué voces
|
||||
// tiene ese equipo, y allí no hay sesión iniciada. No expone nada: solo
|
||||
// lista las voces del navegador y lee una frase de ejemplo inventada.
|
||||
'turnero/voces',
|
||||
];
|
||||
|
||||
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
|
||||
|
||||
+6
-66
@@ -16,11 +16,11 @@ $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 (in_array($rol, ['admin', 'superadmin']) && isset($_GET['eid'])) {
|
||||
if ($rol === 'admin' && isset($_GET['eid'])) {
|
||||
$enfId = (int)$_GET['eid'];
|
||||
}
|
||||
|
||||
if (!in_array($rol, ['admin', 'superadmin', 'enfermero'])) {
|
||||
if ($rol !== 'admin' && $rol !== 'enfermero') {
|
||||
header('Location: index.php'); exit;
|
||||
}
|
||||
|
||||
@@ -761,7 +761,7 @@ const portal = {
|
||||
? (()=>{
|
||||
const tel = item.paciente_telefono.replace(/\D/g,'');
|
||||
const txt = encodeURIComponent(
|
||||
'Hola, soy ' + ENFERMERO_NOMBRE + ' del Laboratorio Ximena Caicedo, el profesional de salud asignado a su atenci\u00f3n. '+
|
||||
'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"
|
||||
@@ -783,11 +783,6 @@ const portal = {
|
||||
onclick="formVer.abrir(${item.paciente_id}, '${esc(item.paciente_nombre||'')}')">
|
||||
<i class="fas fa-folder-open me-1"></i>Ver llenados
|
||||
</button>` : ''}
|
||||
${!['completado','cancelado'].includes(est) ? `
|
||||
<button class="btn btn-accion" style="background:#0d6efd;color:#fff"
|
||||
onclick="agendaNueva.abrirEditar(${item.domicilio_id})">
|
||||
<i class="fas fa-pen me-1"></i>Editar
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -1457,7 +1452,6 @@ const agendaNueva = {
|
||||
_modal: null,
|
||||
_buscarTimer: null,
|
||||
_nuevoPac: false,
|
||||
_editId: null,
|
||||
|
||||
init() {
|
||||
this._modal = new bootstrap.Modal('#modalNuevaAgenda');
|
||||
@@ -1506,63 +1500,12 @@ const agendaNueva = {
|
||||
document.getElementById('na-np-email').value = '';
|
||||
document.getElementById('na-crear-toggle-txt').textContent = 'Crear nuevo paciente';
|
||||
document.getElementById('na-paciente-buscar').disabled = false;
|
||||
this._editId = null;
|
||||
const btn = document.getElementById('na-btn-guardar');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar';
|
||||
document.querySelector('#modalNuevaAgenda .modal-title').textContent = 'Nuevo domicilio';
|
||||
this._modal.show();
|
||||
},
|
||||
|
||||
async abrirEditar(domId) {
|
||||
try {
|
||||
const r = await fetch(`api/lab/get_domicilios.php?id=${domId}`);
|
||||
const d = await r.json();
|
||||
const dom = d.domicilio || d.data?.[0] || null;
|
||||
if (!dom) { alert('No se pudo cargar el domicilio.'); return; }
|
||||
|
||||
this.abrir(); // resetea el form
|
||||
this._editId = domId;
|
||||
|
||||
// Paciente
|
||||
if (dom.paciente_id) {
|
||||
document.getElementById('na-paciente-id').value = dom.paciente_id;
|
||||
document.getElementById('na-paciente-buscar').value = dom.paciente_nombre || '';
|
||||
document.getElementById('na-paciente-label').textContent = dom.paciente_nombre || '';
|
||||
document.getElementById('na-paciente-elegido').classList.remove('d-none');
|
||||
document.getElementById('na-sugerencias').classList.add('d-none');
|
||||
}
|
||||
|
||||
// Campos
|
||||
document.getElementById('na-direccion').value = dom.direccion || '';
|
||||
document.getElementById('na-barrio').value = dom.barrio || '';
|
||||
document.getElementById('na-ciudad').value = dom.ciudad || '';
|
||||
document.getElementById('na-indicaciones').value = dom.indicaciones_dir || '';
|
||||
document.getElementById('na-fecha').value = dom.fecha_programada || '';
|
||||
document.getElementById('na-hora').value = (dom.hora_programada || '').slice(0,5);
|
||||
document.getElementById('na-notas').value = dom.notas_admin || '';
|
||||
document.getElementById('na-valor-dom').value = dom.valor_domicilio || '';
|
||||
document.getElementById('na-valor-cop').value = dom.valor_copago || '';
|
||||
|
||||
// Tipo cliente
|
||||
const tc = dom.tipo_cliente === 'seguro' ? 'na-tc-seguro' : 'na-tc-particular';
|
||||
const tcEl = document.getElementById(tc);
|
||||
if (tcEl) { tcEl.checked = true; this._toggleSeguro(); }
|
||||
if (dom.seguro_nombre) {
|
||||
const sn = document.getElementById('na-seguro-nombre');
|
||||
if (sn) sn.value = dom.seguro_nombre;
|
||||
}
|
||||
|
||||
// Botón y título
|
||||
const btn = document.getElementById('na-btn-guardar');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar cambios';
|
||||
document.querySelector('#modalNuevaAgenda .modal-title').textContent = 'Editar domicilio';
|
||||
} catch(e) {
|
||||
alert('Error al cargar domicilio: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async _buscarPacientes(q) {
|
||||
if (q.length < 2) {
|
||||
document.getElementById('na-sugerencias').classList.add('d-none');
|
||||
@@ -1840,8 +1783,8 @@ const agendaNueva = {
|
||||
valor_domicilio: parseFloat(document.getElementById('na-valor-dom').value) || null,
|
||||
valor_copago: parseFloat(document.getElementById('na-valor-cop').value) || null,
|
||||
notas_admin: document.getElementById('na-notas').value.trim() || null,
|
||||
...(this._editId ? { id: this._editId } : { estado: 'programado' }),
|
||||
...(ENFERMERA_ID && !this._editId ? { enfermera_id: ENFERMERA_ID } : {}),
|
||||
estado: 'programado',
|
||||
...(ENFERMERA_ID ? { enfermera_id: ENFERMERA_ID } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1871,13 +1814,10 @@ const agendaNueva = {
|
||||
}
|
||||
|
||||
this._modal.hide();
|
||||
this._editId = null;
|
||||
portal.cargar();
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = this._editId
|
||||
? '<i class="fas fa-save me-1"></i>Guardar cambios'
|
||||
: '<i class="fas fa-calendar-check me-1"></i>Agendar';
|
||||
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar';
|
||||
this._mostrarError(e.message);
|
||||
}
|
||||
},
|
||||
|
||||
+5
-207
@@ -90,29 +90,6 @@ if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
/* ── Loading screen ──────────────────────────────── */
|
||||
#loading-screen { min-height:50vh; display:flex; flex-direction:column;
|
||||
align-items:center; justify-content:center; gap:12px; }
|
||||
|
||||
/* ── Topaz SigWeb overlay ─────────────────────────── */
|
||||
#topaz-overlay { display:none; position:fixed; inset:0; z-index:9999;
|
||||
background:rgba(0,0,0,.55); align-items:center;
|
||||
justify-content:center; padding:16px; }
|
||||
.topaz-modal { background:#fff; border-radius:16px; width:100%;
|
||||
max-width:400px; box-shadow:0 12px 40px rgba(0,0,0,.3); overflow:hidden; }
|
||||
.topaz-modal-hdr { background:linear-gradient(135deg,#1565c0,#0288d1);
|
||||
color:#fff; padding:14px 18px; font-weight:700; font-size:.95rem;
|
||||
display:flex; align-items:center; gap:8px; }
|
||||
.topaz-modal-body { padding:20px 18px; }
|
||||
.topaz-pad-area { border:2px dashed #adb5bd; border-radius:10px; padding:24px 16px;
|
||||
text-align:center; background:#f8fafc; min-height:100px;
|
||||
display:flex; flex-direction:column; align-items:center;
|
||||
justify-content:center; gap:6px; transition:border-color .2s; }
|
||||
.topaz-pad-area.has-sig { border-color:#198754; border-style:solid; background:#f0fff4; }
|
||||
.topaz-pts-badge { font-size:.8rem; color:#64748b; }
|
||||
.topaz-modal-footer { display:flex; gap:8px; justify-content:flex-end;
|
||||
padding:12px 18px; border-top:1px solid #f1f5f9; flex-wrap:wrap; }
|
||||
.btn-topaz { font-size:.83rem; padding:6px 13px; border-radius:8px; border:1.5px solid #0288d1;
|
||||
background:#fff; color:#0288d1; cursor:pointer; font-weight:600;
|
||||
display:inline-flex; align-items:center; gap:5px; transition:all .15s; }
|
||||
.btn-topaz:hover { background:#e0f2fe; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -227,15 +204,11 @@ if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
<div id="firma-canvas-area">
|
||||
<p class="text-muted small mb-2">✍️ Dibuja tu firma con el dedo o el mouse.</p>
|
||||
<canvas id="firma-canvas" class="empty"></canvas>
|
||||
<div class="d-flex gap-2 mt-2 flex-wrap">
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
onclick="firma.limpiarCanvas()">
|
||||
<i class="fas fa-eraser me-1"></i>Limpiar firma
|
||||
</button>
|
||||
<button type="button" class="btn-topaz" onclick="topaz.activar('__global')"
|
||||
title="Usar pad biométrico Topaz">
|
||||
<i class="fas fa-tablet-alt"></i>Tableta
|
||||
</button>
|
||||
<span id="firma-status" class="small text-muted align-self-center">Sin firma</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,37 +294,6 @@ if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Topaz SigWeb overlay ───────────────────────────────────────── -->
|
||||
<div id="topaz-overlay">
|
||||
<div class="topaz-modal">
|
||||
<div class="topaz-modal-hdr">
|
||||
<i class="fas fa-tablet-alt"></i> Pad biométrico Topaz
|
||||
</div>
|
||||
<div class="topaz-modal-body">
|
||||
<div class="topaz-pad-area" id="topaz-pad-area">
|
||||
<canvas id="topaz-canvas" width="500" height="150"
|
||||
style="border:1px solid #e2e8f0;border-radius:6px;background:#fff;max-width:100%;display:block;margin:0 auto"></canvas>
|
||||
<div id="topaz-status-msg" style="font-size:.9rem;color:#64748b;font-weight:600;margin-top:8px">
|
||||
Firme en el pad biométrico
|
||||
</div>
|
||||
<div class="topaz-pts-badge">Trazos: <span id="topaz-pts">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topaz-modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="topaz.cancelar()">
|
||||
<i class="fas fa-times me-1"></i>Cancelar
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="topaz.limpiarPad()">
|
||||
<i class="fas fa-eraser me-1"></i>Limpiar
|
||||
</button>
|
||||
<button class="btn btn-success btn-sm fw-semibold" id="topaz-btn-aceptar"
|
||||
onclick="topaz.aceptar()" disabled>
|
||||
<i class="fas fa-check me-1"></i>Aceptar firma
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -598,12 +540,12 @@ function renderCampos(esquema, prefilled) {
|
||||
</div>` + wrapClose;
|
||||
} else if (c.tipo === 'radio') {
|
||||
const opts = (c.options||[]).map(o => `
|
||||
<div class="form-check form-check-inline me-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="${c.id}" value="${esc(o)}"
|
||||
${o===val?'checked':''} ${isReadOnly?'disabled':''}>
|
||||
<label class="form-check-label">${esc(o)}</label>
|
||||
</div>`).join('');
|
||||
html += wrapOpen + `<div class="mb-3">${lbl}<div class="d-flex flex-wrap gap-1">${opts}</div>${linkedNote}</div>` + wrapClose;
|
||||
html += wrapOpen + `<div class="mb-3">${lbl}${opts}${linkedNote}</div>` + wrapClose;
|
||||
} else if (c.tipo === 'checkbox') {
|
||||
const vals = Array.isArray(val) ? val : [];
|
||||
const opts = (c.options||[]).map(o => `
|
||||
@@ -706,15 +648,11 @@ function renderFirmaInline(fid, modos, label, required, isPro) {
|
||||
<p class="text-muted small mb-2">✍️ Dibuja la firma con el dedo o el mouse.</p>
|
||||
<canvas id="fw-${fid}-canvas" class="fw-canvas empty"
|
||||
style="border:2px dashed ${borderC};background:${bgC};"></canvas>
|
||||
<div class="d-flex gap-2 mt-2 flex-wrap">
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
onclick="firmaWidgetLimpiar('${fid}')">
|
||||
<i class="fas fa-eraser me-1"></i>Limpiar
|
||||
</button>
|
||||
<button type="button" class="btn-topaz" onclick="topaz.activar('${fid}')"
|
||||
title="Usar pad biométrico Topaz">
|
||||
<i class="fas fa-tablet-alt"></i>Tableta
|
||||
</button>
|
||||
<span id="fw-${fid}-status" class="small text-muted align-self-center">Sin firma</span>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -1234,8 +1172,7 @@ function iniciarCondiciones(esquema) {
|
||||
} else {
|
||||
const radio = ctrl.querySelector(`input[type="radio"]:checked`);
|
||||
const sel = ctrl.querySelector('select');
|
||||
const inp = ctrl.querySelector('input[type="text"],input[type="hidden"]');
|
||||
const v = radio ? radio.value : (sel ? sel.value : (inp ? inp.value : ''));
|
||||
const v = radio ? radio.value : (sel ? sel.value : '');
|
||||
activo = valoresCond.includes(v);
|
||||
}
|
||||
|
||||
@@ -1268,145 +1205,6 @@ function iniciarCondiciones(esquema) {
|
||||
evaluar();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// TOPAZ SIGWEB
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const topaz = (() => {
|
||||
const SIGWEB_URL = '/assets/js/SigWebTablet.js';
|
||||
let _loaded = false, _fid = null, _poll = null, _tmr = null;
|
||||
|
||||
function _loadScript() {
|
||||
if (_loaded) return Promise.resolve(true);
|
||||
return new Promise(res => {
|
||||
const s = document.createElement('script');
|
||||
s.src = SIGWEB_URL;
|
||||
s.onload = () => { _loaded = true; res(true); };
|
||||
s.onerror = () => res(false);
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function _setStatus(msg, ok) {
|
||||
const el = $('topaz-status-msg');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.color = ok ? '#198754' : '#64748b';
|
||||
const area = $('topaz-pad-area');
|
||||
if (area) area.classList.toggle('has-sig', !!ok);
|
||||
const icon = $('topaz-pad-icon');
|
||||
if (icon) icon.style.color = ok ? '#198754' : '#adb5bd';
|
||||
}
|
||||
|
||||
async function activar(fid) {
|
||||
const ok = await _loadScript();
|
||||
if (!ok) {
|
||||
alert('No se detectó SigWeb.\nInstala el servicio Topaz SigWeb y vuelve a intentarlo.');
|
||||
return;
|
||||
}
|
||||
_fid = fid;
|
||||
try {
|
||||
const canvas = document.getElementById('topaz-canvas');
|
||||
const canvasCtx = canvas.getContext('2d');
|
||||
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
SetImageXSize(500);
|
||||
SetImageYSize(150);
|
||||
SetImagePenWidth(3);
|
||||
ClearTablet();
|
||||
_tmr = SetTabletState(1, canvasCtx, 50);
|
||||
} catch(e) {
|
||||
alert('Error al activar el pad: ' + e.message);
|
||||
return;
|
||||
}
|
||||
$('topaz-overlay').style.display = 'flex';
|
||||
$('topaz-btn-aceptar').disabled = true;
|
||||
$('topaz-pts').textContent = '0';
|
||||
_setStatus('Firme en el pad biométrico', false);
|
||||
|
||||
let _lastPts = -1;
|
||||
_poll = setInterval(() => {
|
||||
try {
|
||||
const pts = NumberOfTabletPoints();
|
||||
$('topaz-pts').textContent = pts;
|
||||
const hasSig = pts > 0;
|
||||
$('topaz-btn-aceptar').disabled = !hasSig;
|
||||
if (hasSig) {
|
||||
_setStatus('✅ Firma detectada — presione Aceptar', true);
|
||||
if (pts !== _lastPts) {
|
||||
_lastPts = pts;
|
||||
GetSigImageB64(function(b64) {
|
||||
if (!b64) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const cv = document.getElementById('topaz-canvas');
|
||||
const cx = cv.getContext('2d');
|
||||
cx.clearRect(0, 0, cv.width, cv.height);
|
||||
cx.drawImage(img, 0, 0, cv.width, cv.height);
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + b64;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(e) { _stopPoll(); }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function _stopPoll() { if (_poll) { clearInterval(_poll); _poll = null; } }
|
||||
|
||||
function _cerrarOverlay() {
|
||||
_stopPoll();
|
||||
try { SetTabletState(0, _tmr); } catch(e) {}
|
||||
_tmr = null; _fid = null;
|
||||
$('topaz-overlay').style.display = 'none';
|
||||
}
|
||||
|
||||
function cancelar() { _cerrarOverlay(); }
|
||||
|
||||
function limpiarPad() {
|
||||
try { _call('ClearTablet'); } catch(e) {}
|
||||
$('topaz-pts').textContent = '0';
|
||||
$('topaz-btn-aceptar').disabled = true;
|
||||
_setStatus('Firme en el pad biométrico', false);
|
||||
}
|
||||
|
||||
function aceptar() {
|
||||
const fid = _fid;
|
||||
_cerrarOverlay();
|
||||
try {
|
||||
GetSigImageB64(function(b64) {
|
||||
if (!b64) { alert('No se capturó ninguna firma.'); return; }
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (fid === '__global') {
|
||||
const c = $('firma-canvas');
|
||||
const cx = c.getContext('2d');
|
||||
const r = window.devicePixelRatio || 1;
|
||||
cx.clearRect(0, 0, c.width / r, c.height / r);
|
||||
cx.drawImage(img, 0, 0, c.offsetWidth, c.offsetHeight);
|
||||
c.classList.remove('empty');
|
||||
_firmaDibujada = true;
|
||||
const st = $('firma-status');
|
||||
if (st) { st.textContent = '✅ Firma lista (tableta)'; st.className = 'small text-success align-self-center fw-semibold'; }
|
||||
} else {
|
||||
const s = _fw[fid];
|
||||
if (!s?.canvas) return;
|
||||
const r = window.devicePixelRatio || 1;
|
||||
s.ctx.clearRect(0, 0, s.canvas.width / r, s.canvas.height / r);
|
||||
s.ctx.drawImage(img, 0, 0, s.canvas.offsetWidth, s.canvas.offsetHeight);
|
||||
s.hasFirma = true;
|
||||
s.canvas.classList.remove('empty');
|
||||
s.canvas.style.borderStyle = 'solid';
|
||||
const st = document.getElementById('fw-' + fid + '-status');
|
||||
if (st) { st.textContent = '✅ Firmado (tableta)'; st.className = 'small text-success align-self-center fw-semibold'; }
|
||||
}
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + b64;
|
||||
});
|
||||
} catch(e) { alert('Error al capturar la firma: ' + e.message); }
|
||||
}
|
||||
|
||||
return { activar, cancelar, limpiarPad, aceptar };
|
||||
})();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// INIT
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -19,16 +19,9 @@ if (isEnfermero()) {
|
||||
}
|
||||
$_indexRole = $_SESSION['admin_user']['role'] ?? '';
|
||||
$_indexModules = $_SESSION['admin_user']['modules'] ?? [];
|
||||
if ($_indexRole === 'recepcionista') {
|
||||
if ($_indexRole === 'recepcionista' || (in_array('turnero', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true))) {
|
||||
header('Location: erp.php?m=turnero&v=recepcion'); exit;
|
||||
}
|
||||
if (in_array('turnero', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true)) {
|
||||
$_indexAdminRoles = ['superadmin', 'admin', 'supervisor', 'bacteriologo'];
|
||||
$url = in_array($_indexRole, $_indexAdminRoles, true)
|
||||
? 'erp.php?m=turnero&v=dashboard'
|
||||
: 'erp.php?m=turnero&v=recepcion';
|
||||
header('Location: ' . $url); exit;
|
||||
}
|
||||
if (in_array('lab_dashboard', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true)) {
|
||||
header('Location: lab_dashboard.php'); exit;
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
; kiosko_autoprint.ahk
|
||||
; Detecta el diálogo de impresión y lo confirma automáticamente.
|
||||
; Requiere AutoHotkey v2: https://www.autohotkey.com/
|
||||
|
||||
#Persistent
|
||||
SetTitleMatchMode 2
|
||||
|
||||
Loop {
|
||||
; Esperar cualquier ventana de diálogo de impresión (Chrome, Edge, Windows)
|
||||
if WinExist("Imprimir") or WinExist("Print") {
|
||||
WinActivate
|
||||
Sleep 400
|
||||
; Intentar presionar el botón Imprimir / OK / Enter
|
||||
ControlClick "Button1"
|
||||
Sleep 200
|
||||
Send "{Enter}"
|
||||
Sleep 1000
|
||||
}
|
||||
Sleep 500
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@echo off
|
||||
taskkill /F /IM msedge.exe >nul 2>&1
|
||||
timeout /t 2 /nobreak >nul
|
||||
|
||||
start "" "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --kiosk "https://erp.laboratorioximenacaicedo.com/erp.php?m=turnero&v=kiosko&autoprint=1" --edge-kiosk-type=fullscreen --kiosk-printing
|
||||
+3
-17
@@ -681,7 +681,6 @@ async function cargarLista(pag = 1) {
|
||||
<td>
|
||||
<div class="fw-semibold">${esc(dom.paciente_nombre)}</div>
|
||||
<small class="text-muted">${esc(dom.barrio||'')}</small>
|
||||
${dom.numero_orden ? `<div><span style="font-size:.68rem;background:#f0fdf4;color:#15803d;border:1px solid #86efac;border-radius:20px;padding:0 7px;font-weight:700">#${esc(dom.numero_orden)}</span></div>` : ''}
|
||||
</td>
|
||||
<td>
|
||||
${dom.enfermera_nombre ? `<small>${esc(dom.enfermera_nombre)}</small>` : '<span class="text-danger small"><i class="fas fa-exclamation-triangle me-1"></i>Sin asignar</span>'}
|
||||
@@ -800,13 +799,6 @@ async function verDomicilio(id) {
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
${dom.numero_orden ? `<div class="mb-3">
|
||||
<label class="form-label small text-muted text-uppercase">Número de orden</label><br>
|
||||
<span style="font-size:.82rem;background:#f0fdf4;color:#15803d;border:1px solid #86efac;
|
||||
border-radius:20px;padding:2px 12px;font-weight:700">
|
||||
<i class="fas fa-hashtag me-1" style="font-size:.7rem"></i>${esc(dom.numero_orden)}
|
||||
</span>
|
||||
</div>` : ''}
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted text-uppercase">Estado actual</label><br>
|
||||
<span class="badge bg-${COLOR_DOM[dom.estado]||'secondary'} fs-6">${esc(dom.estado)}</span>
|
||||
@@ -1614,16 +1606,10 @@ async function ffExportarExcel() {
|
||||
...fieldCols.map(([l,d])=>{ const v=todos[lmap.get(l)||d]; return v==null?'':(Array.isArray(v)?v.join('; '):String(v)); }),
|
||||
]);
|
||||
});
|
||||
const xlsEsc = v => String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
const toCell = v => `<Cell><Data ss:Type="String">${xlsEsc(v)}</Data></Cell>`;
|
||||
const xml = '<' + '?xml version="1.0" encoding="UTF-8"?>\n<' + '?mso-application progid="Excel.Sheet"?>\n'
|
||||
+ `<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">`
|
||||
+ `<Worksheet ss:Name="Domicilios"><Table>\n`
|
||||
+ csvRows.map(r => `<Row>${r.map(toCell).join('')}</Row>`).join('\n')
|
||||
+ `\n</Table></Worksheet></Workbook>`;
|
||||
const blob = new Blob([xml], {type:'application/vnd.ms-excel;charset=utf-8'});
|
||||
const csv = '\uFEFF' + csvRows.map(r=>r.map(v=>'"'+String(v).replace(/"/g,'""')+'"').join(',')).join('\r\n');
|
||||
const blob = new Blob([csv],{type:'text/csv;charset=utf-8;'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement('a'),{href:url,download:'formularios_domicilios_'+new Date().toISOString().slice(0,10)+'.xls'});
|
||||
const a = Object.assign(document.createElement('a'),{href:url,download:'formularios_domicilios_'+new Date().toISOString().slice(0,10)+'.csv'});
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
|
||||
mostrarToast('✅ Excel descargado — ' + rows.length + ' registro(s)', 'success');
|
||||
}
|
||||
|
||||
+24
-172
@@ -56,28 +56,7 @@ $brandDark = sprintf('#%02x%02x%02x',
|
||||
color: #64748b;
|
||||
}
|
||||
.hist-badge { font-size: .72rem; }
|
||||
/* Drawer de detalle (fixed overlay) */
|
||||
#detail-backdrop {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.25);
|
||||
z-index: 1049;
|
||||
}
|
||||
#detail-panel {
|
||||
position: fixed;
|
||||
top: 0; right: 0;
|
||||
height: 100vh;
|
||||
width: 420px;
|
||||
z-index: 1050;
|
||||
background: #fff;
|
||||
box-shadow: -4px 0 24px rgba(0,0,0,.18);
|
||||
transform: translateX(110%);
|
||||
transition: transform .25s cubic-bezier(.4,0,.2,1);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
#detail-panel.open { transform: translateX(0); }
|
||||
@media (max-width: 480px) { #detail-panel { width: 100vw; } }
|
||||
#detail-body { flex: 1; overflow-y: auto; padding: 1rem; }
|
||||
#detail-panel { display: none; }
|
||||
|
||||
/* ── Modal header ──────────────────────────────── */
|
||||
.modal-header-brand {
|
||||
@@ -146,10 +125,9 @@ $brandDark = sprintf('#%02x%02x%02x',
|
||||
/* ── Panel detalle ─────────────────────────────── */
|
||||
.detail-header-band {
|
||||
background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 100%);
|
||||
color: #fff;
|
||||
color: #fff; border-radius: .5rem .5rem 0 0;
|
||||
padding: .75rem 1rem;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-header-band .btn-close { filter: invert(1) brightness(2); }
|
||||
|
||||
@@ -177,37 +155,8 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
</header>
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row g-2 mb-3" id="stats-row">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm rounded-3 text-center py-2">
|
||||
<div class="fw-bold fs-4" id="stat-total">—</div>
|
||||
<div class="text-muted small">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('lab')">
|
||||
<div class="fw-bold fs-4 text-primary" id="stat-rips">—</div>
|
||||
<div class="text-muted small"><i class="fas fa-flask me-1 text-primary"></i>Importados Lab</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('whatsapp')">
|
||||
<div class="fw-bold fs-4 text-success" id="stat-wa">—</div>
|
||||
<div class="text-muted small"><i class="fab fa-whatsapp me-1 text-success"></i>Vinculados WA</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('manual')">
|
||||
<div class="fw-bold fs-4 text-secondary" id="stat-manual">—</div>
|
||||
<div class="text-muted small"><i class="fas fa-pen me-1"></i>Manuales</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="row mb-3 g-2">
|
||||
<!-- Buscador -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-5">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="fas fa-search text-muted"></i></span>
|
||||
@@ -216,24 +165,11 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
oninput="debounce(cargarLista, 380)()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select id="filtro-origen" class="form-select" onchange="cargarLista(1)">
|
||||
<option value="">Todos los orígenes</option>
|
||||
<option value="lab">Resultados Lab</option>
|
||||
<option value="manual">Manuales</option>
|
||||
<option value="whatsapp">Desde WhatsApp</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-auto ms-auto">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="cargarStats()" title="Actualizar stats">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Lista -->
|
||||
<div class="col-12" id="lista-col">
|
||||
<div class="col-lg-7" id="lista-col">
|
||||
<div class="card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
@@ -243,17 +179,13 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
<th class="ps-3">Paciente</th>
|
||||
<th>Documento</th>
|
||||
<th>Teléfono</th>
|
||||
<th>Email</th>
|
||||
<th>Ciudad</th>
|
||||
<th>EPS</th>
|
||||
<th>Origen</th>
|
||||
<th>Registro</th>
|
||||
<th>Órd.</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tabla-body">
|
||||
<tr><td colspan="10" class="text-center py-4 text-muted">
|
||||
<tr><td colspan="6" class="text-center py-4 text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>Cargando...
|
||||
</td></tr>
|
||||
</tbody>
|
||||
@@ -263,21 +195,20 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Backdrop para el drawer de detalle -->
|
||||
<div id="detail-backdrop" onclick="cerrarDetalle()"></div>
|
||||
|
||||
<!-- Drawer de detalle (fixed overlay) -->
|
||||
<div id="detail-panel">
|
||||
<!-- Panel de detalle -->
|
||||
<div class="col-lg-5" id="detail-panel">
|
||||
<div class="card border-0 shadow-sm rounded-3 overflow-hidden">
|
||||
<div class="detail-header-band">
|
||||
<span class="fw-semibold" id="detail-nombre">Paciente</span>
|
||||
<button class="btn-close" onclick="cerrarDetalle()"></button>
|
||||
</div>
|
||||
<div id="detail-body"></div>
|
||||
<div class="card-body" id="detail-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal Formulario Paciente -->
|
||||
<div class="modal fade" id="modalPaciente" tabindex="-1">
|
||||
@@ -436,76 +367,16 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
let paginaActual = 1;
|
||||
const modal = new bootstrap.Modal('#modalPaciente');
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||
async function cargarStats() {
|
||||
try {
|
||||
const r = await fetch('api/lab/get_pacientes.php?stats=1');
|
||||
const d = await r.json();
|
||||
const s = d.stats || {};
|
||||
document.getElementById('stat-total').textContent = s.total ?? '—';
|
||||
document.getElementById('stat-rips').textContent = s.importados ?? '—';
|
||||
document.getElementById('stat-wa').textContent = s.con_wa ?? '—';
|
||||
document.getElementById('stat-manual').textContent = s.manuales ?? '—';
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
function filtrarOrigen(origen) {
|
||||
const sel = document.getElementById('filtro-origen');
|
||||
sel.value = sel.value === origen ? '' : origen;
|
||||
cargarLista(1);
|
||||
}
|
||||
|
||||
// ── Lista ──────────────────────────────────────────────────────────────────
|
||||
const _origenBadge = {
|
||||
lab: '<span class="badge" style="background:#e3f2fd;color:#1565c0;font-size:.7rem"><i class="fas fa-flask me-1"></i>Resultados Lab</span>',
|
||||
whatsapp: '<span class="badge" style="background:#e8f5e9;color:#2e7d32;font-size:.7rem"><i class="fab fa-whatsapp me-1"></i>WhatsApp</span>',
|
||||
manual: '<span class="badge bg-secondary bg-opacity-10 text-secondary" style="font-size:.7rem"><i class="fas fa-pen me-1"></i>Manual</span>',
|
||||
rips: '<span class="badge" style="background:#e3f2fd;color:#1565c0;font-size:.7rem"><i class="fas fa-flask me-1"></i>Resultados Lab</span>',
|
||||
};
|
||||
|
||||
function origenBadge(origen) {
|
||||
return _origenBadge[origen] || _origenBadge.manual;
|
||||
}
|
||||
|
||||
function fmtFecha(str) {
|
||||
if (!str) return '—';
|
||||
return str.slice(0, 10).split('-').reverse().join('/');
|
||||
}
|
||||
|
||||
// Quien oculta su número en WhatsApp llega identificado con un BSUID
|
||||
// ("CO.1761088155094242"), que se guarda en phone_number pero no es un teléfono.
|
||||
const esBsuid = v => /^[A-Z]{2}\.\d+$/.test(String(v || ''));
|
||||
|
||||
/**
|
||||
* Celda de contacto. Si de esta persona no tenemos teléfono porque lo tiene
|
||||
* oculto, se dice así en vez de mostrar el identificador crudo: recepción
|
||||
* necesita entender por qué no puede llamarla, no ver un código.
|
||||
*/
|
||||
function celdaTelefono(p) {
|
||||
if (esBsuid(p.phone_number)) {
|
||||
return (p.telefono ? esc(p.telefono) : '<span class="text-muted">Sin teléfono</span>')
|
||||
+ '<br><i class="fab fa-whatsapp text-success"></i> '
|
||||
+ '<small class="text-muted" title="Tiene el número oculto en WhatsApp. '
|
||||
+ 'Se le puede escribir por el chat, pero no llamar.">Solo por WhatsApp</small>';
|
||||
}
|
||||
return esc(p.telefono || '—')
|
||||
+ (p.phone_number
|
||||
? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>`
|
||||
: '');
|
||||
}
|
||||
|
||||
async function cargarLista(pag = 1) {
|
||||
paginaActual = pag;
|
||||
const busq = document.getElementById('buscador').value.trim();
|
||||
const origen = document.getElementById('filtro-origen').value;
|
||||
const r = await fetch(
|
||||
`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25&origen=${encodeURIComponent(origen)}`
|
||||
);
|
||||
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25`);
|
||||
const d = await r.json();
|
||||
|
||||
const tbody = document.getElementById('tabla-body');
|
||||
if (!d.data?.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Sin resultados</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Sin resultados</td></tr>';
|
||||
document.getElementById('paginacion').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
@@ -514,15 +385,11 @@ async function cargarLista(pag = 1) {
|
||||
<tr style="cursor:pointer" onclick="verDetalle(${p.id})">
|
||||
<td class="ps-3">
|
||||
<div class="fw-semibold">${esc(p.nombre_completo)}</div>
|
||||
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
|
||||
${p.phone_number ? `<small class="text-muted"><i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}</small>` : ''}
|
||||
</td>
|
||||
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
|
||||
<td class="small">${celdaTelefono(p)}</td>
|
||||
<td class="small text-muted">${esc(p.email||'—')}</td>
|
||||
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
||||
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
||||
<td>${origenBadge(p.origen)}</td>
|
||||
<td class="small text-muted">${fmtFecha(p.created_at)}</td>
|
||||
<td class="small">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td>
|
||||
<td class="small">${esc(p.telefono||'—')}</td>
|
||||
<td class="small">${esc(p.eps||'—')}</td>
|
||||
<td><span class="badge bg-primary hist-badge">${p.total_ordenes||0}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary py-1 px-2"
|
||||
@@ -550,8 +417,7 @@ async function cargarLista(pag = 1) {
|
||||
// ── Detalle ────────────────────────────────────────────────────────────────
|
||||
async function verDetalle(id) {
|
||||
const panel = document.getElementById('detail-panel');
|
||||
panel.classList.add('open');
|
||||
document.getElementById('detail-backdrop').style.display = 'block';
|
||||
panel.style.display = 'block';
|
||||
document.getElementById('detail-body').innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-lg text-muted"></i></div>';
|
||||
|
||||
const r = await fetch(`api/lab/get_pacientes.php?id=${id}`);
|
||||
@@ -568,20 +434,8 @@ async function verDetalle(id) {
|
||||
document.getElementById('detail-body').innerHTML = `
|
||||
<dl class="row small mb-3">
|
||||
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${
|
||||
p.telefono ? esc(p.telefono)
|
||||
: (esBsuid(p.phone_number)
|
||||
? '<span class="text-muted">Sin teléfono — lo tiene oculto en WhatsApp</span>'
|
||||
: '—')
|
||||
}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${
|
||||
!p.phone_number ? '—'
|
||||
: (esBsuid(p.phone_number)
|
||||
? '<i class="fab fa-whatsapp text-success"></i> Se le puede escribir por el chat, pero no llamar'
|
||||
: `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}`)
|
||||
}</dd>
|
||||
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
|
||||
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
|
||||
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
|
||||
${p.fecha_nacimiento ? `<dt class="col-5 text-muted">Nacimiento</dt><dd class="col-7">${esc(p.fecha_nacimiento.slice(0,10))}</dd>` : ''}
|
||||
${p.genero ? `<dt class="col-5 text-muted">Género</dt><dd class="col-7">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</dd>` : ''}
|
||||
@@ -611,8 +465,7 @@ async function verDetalle(id) {
|
||||
}
|
||||
|
||||
function cerrarDetalle() {
|
||||
document.getElementById('detail-panel').classList.remove('open');
|
||||
document.getElementById('detail-backdrop').style.display = 'none';
|
||||
document.getElementById('detail-panel').style.display = 'none';
|
||||
}
|
||||
|
||||
// ── Formulario ─────────────────────────────────────────────────────────────
|
||||
@@ -775,7 +628,6 @@ function mostrarToast(msg, tipo = 'success') {
|
||||
}
|
||||
|
||||
cargarLista();
|
||||
cargarStats();
|
||||
</script>
|
||||
<script src="assets/js/lab-sidebar.js"></script>
|
||||
</body>
|
||||
|
||||
+10
-14
@@ -35,7 +35,7 @@ if ($tipo) {
|
||||
ORDER BY o.created_at DESC",
|
||||
[$desde, $hasta]
|
||||
);
|
||||
$filename = "ordenes_$desde\_$hasta.xls";
|
||||
$filename = "ordenes_$desde\_$hasta.csv";
|
||||
$headers = ['ID','Paciente','Documento','EPS','Estado','Médico','Fecha Orden','Exámenes','Ayuno','H.Ayuno','Creada','Revisada por','Autorizada por'];
|
||||
break;
|
||||
|
||||
@@ -109,7 +109,7 @@ if ($tipo) {
|
||||
$sqlParams
|
||||
);
|
||||
$rangoLabel = !empty($_GET['fecha']) ? $_GET['fecha'] : "{$desde}_{$hasta}";
|
||||
$filename = "domicilios_{$rangoLabel}.xls";
|
||||
$filename = "domicilios_{$rangoLabel}.csv";
|
||||
$headers = [
|
||||
'ID','Paciente','Documento','Teléfono','EPS',
|
||||
'Fecha','Hora programada','Hora llegada','Hora salida','Duración',
|
||||
@@ -133,7 +133,7 @@ if ($tipo) {
|
||||
ORDER BY p.nombre_completo",
|
||||
[]
|
||||
);
|
||||
$filename = "pacientes_" . date('Y-m-d') . ".xls";
|
||||
$filename = "pacientes_" . date('Y-m-d') . ".csv";
|
||||
$headers = ['ID','Nombre','Tipo Doc.','Documento','Teléfono','Email','EPS','Ciudad','Barrio','Activo','Total Órdenes','Registrado'];
|
||||
break;
|
||||
|
||||
@@ -191,7 +191,7 @@ if ($tipo) {
|
||||
ORDER BY d.fecha_programada ASC, d.id ASC",
|
||||
$pParams
|
||||
);
|
||||
$filename = "reporte_pagos_{$rangoNombre}.xls";
|
||||
$filename = "reporte_pagos_{$rangoNombre}.csv";
|
||||
$headers = [
|
||||
'ID','Fecha programada','Fecha pago','Paciente','Documento',
|
||||
'Enfermero/a','Tipo cliente','Seguro','Estado domicilio',
|
||||
@@ -204,21 +204,17 @@ if ($tipo) {
|
||||
exit('Tipo de exportación no válido');
|
||||
}
|
||||
|
||||
header('Content-Type: application/vnd.ms-excel; charset=utf-8');
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header("Content-Disposition: attachment; filename=\"$filename\"");
|
||||
header('Pragma: no-cache');
|
||||
|
||||
$esc = fn($v) => htmlspecialchars((string)$v, ENT_XML1, 'UTF-8');
|
||||
echo '<' . '?xml version="1.0" encoding="UTF-8"?' . '>' . "\n";
|
||||
echo '<' . '?mso-application progid="Excel.Sheet"?' . '>' . "\n";
|
||||
echo '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"' . "\n";
|
||||
echo ' xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">' . "\n";
|
||||
echo '<Worksheet ss:Name="Reporte"><Table>' . "\n";
|
||||
echo '<Row>' . implode('', array_map(fn($h) => '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>', $headers)) . '</Row>' . "\n";
|
||||
$f = fopen('php://output', 'w');
|
||||
fputs($f, "\xEF\xBB\xBF"); // BOM UTF-8 para Excel
|
||||
fputcsv($f, $headers);
|
||||
foreach ($rows as $row) {
|
||||
echo '<Row>' . implode('', array_map(fn($v) => '<Cell><Data ss:Type="String">' . $esc($v) . '</Data></Cell>', array_values($row))) . '</Row>' . "\n";
|
||||
fputcsv($f, array_values($row));
|
||||
}
|
||||
echo '</Table></Worksheet></Workbook>';
|
||||
fclose($f);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -210,56 +210,6 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
MODAL — FIRMA DE USUARIO
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<div class="modal fade" id="modalFirmaUsuario" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-signature me-2"></i>Firma de <span id="firma-u-nombre"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="firma-u-id">
|
||||
|
||||
<!-- Firma actual -->
|
||||
<div id="firma-u-actual" class="mb-3 d-none">
|
||||
<label class="form-label small fw-semibold text-muted">Firma guardada</label>
|
||||
<div class="border rounded p-2 text-center bg-light">
|
||||
<img id="firma-u-img" src="" alt="Firma" style="max-height:80px;max-width:100%">
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm mt-2 w-100" onclick="firmaUsuario.borrar()">
|
||||
<i class="fas fa-trash me-1"></i>Eliminar firma guardada
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Canvas para dibujar -->
|
||||
<label class="form-label small fw-semibold">Dibujar nueva firma</label>
|
||||
<div style="border:1px solid #dee2e6;border-radius:8px;background:#fff;touch-action:none">
|
||||
<canvas id="firma-u-canvas" width="460" height="140" style="width:100%;height:140px;border-radius:8px;cursor:crosshair"></canvas>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="firmaUsuario.limpiarCanvas()">
|
||||
<i class="fas fa-eraser me-1"></i>Limpiar
|
||||
</button>
|
||||
<div class="ms-auto">
|
||||
<label class="form-label small fw-semibold mb-0 me-2">O subir imagen:</label>
|
||||
<input type="file" id="firma-u-file" accept="image/*" class="form-control form-control-sm d-inline-block" style="width:auto" onchange="firmaUsuario.cargarImagen(this)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-danger mt-2 d-none" id="firma-u-error"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-primary" onclick="firmaUsuario.guardar()">
|
||||
<i class="fas fa-save me-1"></i>Guardar firma
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
MODAL — ROL
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
@@ -510,11 +460,6 @@ const usuarios = {
|
||||
<td class="text-muted small">${lastLogin}</td>
|
||||
<td class="text-end table-actions">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="usuarios.editar(${u.id})"><i class="fas fa-edit"></i></button>
|
||||
<button class="btn btn-sm ${u.tiene_firma ? 'btn-success' : 'btn-outline-secondary'} ms-1"
|
||||
onclick="firmaUsuario.abrir(${u.id}, '${esc(u.full_name ?? u.username)}')"
|
||||
title="${u.tiene_firma ? 'Ver / cambiar firma' : 'Subir firma'}">
|
||||
<i class="fas fa-signature"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger ms-1" onclick="usuarios.eliminar(${u.id},'${esc(u.username)}')"><i class="fas fa-trash-alt"></i></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
@@ -651,142 +596,6 @@ document.getElementById('r_slug').addEventListener('input', function() {
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ── Gestión de firma por usuario ────────────────────────────
|
||||
const firmaUsuario = (() => {
|
||||
let _canvas, _ctx, _drawing = false;
|
||||
|
||||
function initCanvas() {
|
||||
_canvas = document.getElementById('firma-u-canvas');
|
||||
_ctx = _canvas.getContext('2d');
|
||||
_ctx.strokeStyle = '#1e293b';
|
||||
_ctx.lineWidth = 2.2;
|
||||
_ctx.lineCap = 'round';
|
||||
|
||||
const pos = e => {
|
||||
const r = _canvas.getBoundingClientRect();
|
||||
const t = e.touches?.[0] ?? e;
|
||||
return [(t.clientX - r.left) * (_canvas.width / r.width),
|
||||
(t.clientY - r.top) * (_canvas.height / r.height)];
|
||||
};
|
||||
const start = e => { e.preventDefault(); _drawing = true; _ctx.beginPath(); _ctx.moveTo(...pos(e)); };
|
||||
const move = e => { e.preventDefault(); if (!_drawing) return; _ctx.lineTo(...pos(e)); _ctx.stroke(); };
|
||||
const stop = () => { _drawing = false; };
|
||||
|
||||
_canvas.addEventListener('mousedown', start);
|
||||
_canvas.addEventListener('mousemove', move);
|
||||
_canvas.addEventListener('mouseup', stop);
|
||||
_canvas.addEventListener('mouseleave', stop);
|
||||
_canvas.addEventListener('touchstart', start, { passive: false });
|
||||
_canvas.addEventListener('touchmove', move, { passive: false });
|
||||
_canvas.addEventListener('touchend', stop);
|
||||
}
|
||||
|
||||
return {
|
||||
abrir(userId, nombre) {
|
||||
document.getElementById('firma-u-id').value = userId;
|
||||
document.getElementById('firma-u-nombre').textContent = nombre;
|
||||
document.getElementById('firma-u-error').classList.add('d-none');
|
||||
document.getElementById('firma-u-file').value = '';
|
||||
|
||||
// Mostrar firma actual si existe
|
||||
const u = (typeof allUsers !== 'undefined' ? allUsers : []).find(x => x.id === userId);
|
||||
const actualEl = document.getElementById('firma-u-actual');
|
||||
if (u?.tiene_firma) {
|
||||
// Cargar imagen desde servidor
|
||||
fetch(`api/lab/get_firma_usuario.php?user_id=${userId}`)
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.firma_svg) {
|
||||
document.getElementById('firma-u-img').src = j.firma_svg;
|
||||
actualEl.classList.remove('d-none');
|
||||
}
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
actualEl.classList.add('d-none');
|
||||
}
|
||||
|
||||
if (!_canvas) initCanvas();
|
||||
this.limpiarCanvas();
|
||||
new bootstrap.Modal('#modalFirmaUsuario').show();
|
||||
},
|
||||
|
||||
limpiarCanvas() {
|
||||
if (_ctx) _ctx.clearRect(0, 0, _canvas.width, _canvas.height);
|
||||
},
|
||||
|
||||
cargarImagen(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!_canvas) initCanvas();
|
||||
this.limpiarCanvas();
|
||||
const scale = Math.min(_canvas.width / img.width, _canvas.height / img.height);
|
||||
const w = img.width * scale, h = img.height * scale;
|
||||
_ctx.drawImage(img, (_canvas.width - w) / 2, (_canvas.height - h) / 2, w, h);
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
|
||||
async guardar() {
|
||||
const userId = parseInt(document.getElementById('firma-u-id').value);
|
||||
const errEl = document.getElementById('firma-u-error');
|
||||
errEl.classList.add('d-none');
|
||||
|
||||
// Verificar que el canvas tenga algo dibujado
|
||||
const blank = document.createElement('canvas');
|
||||
blank.width = _canvas.width; blank.height = _canvas.height;
|
||||
if (_canvas.toDataURL() === blank.toDataURL()) {
|
||||
errEl.textContent = 'Dibuja o sube una firma primero.';
|
||||
errEl.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const png = _canvas.toDataURL('image/png');
|
||||
try {
|
||||
const res = await fetch('api/lab/save_firma_usuario.php', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, firma_svg: png })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) { errEl.textContent = json.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||
bootstrap.Modal.getInstance(document.getElementById('modalFirmaUsuario'))?.hide();
|
||||
// Marcar tiene_firma en memoria local para actualizar el botón
|
||||
if (typeof allUsers !== 'undefined') {
|
||||
const u = allUsers.find(x => x.id === userId);
|
||||
if (u) u.tiene_firma = true;
|
||||
if (typeof usuarios !== 'undefined') usuarios.render();
|
||||
}
|
||||
} catch (e) { errEl.textContent = 'Error de conexión.'; errEl.classList.remove('d-none'); }
|
||||
},
|
||||
|
||||
async borrar() {
|
||||
const userId = parseInt(document.getElementById('firma-u-id').value);
|
||||
if (!confirm('¿Eliminar la firma guardada de este usuario?')) return;
|
||||
try {
|
||||
const res = await fetch('api/lab/save_firma_usuario.php', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, _borrar: true })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) return;
|
||||
document.getElementById('firma-u-actual').classList.add('d-none');
|
||||
if (typeof allUsers !== 'undefined') {
|
||||
const u = allUsers.find(x => x.id === userId);
|
||||
if (u) u.tiene_firma = false;
|
||||
if (typeof usuarios !== 'undefined') usuarios.render();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script src="assets/js/lab-sidebar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -41,40 +41,6 @@ if ($_POST && !$loginBlocked) {
|
||||
$_SESSION['login_ip'] = $clientIp;
|
||||
$_SESSION['login_time'] = time();
|
||||
|
||||
// Tablet fija: redirigir al lugar asignado por token o IP
|
||||
try {
|
||||
$_pdo2 = Database::getInstance()->getConnection();
|
||||
$_disp = null;
|
||||
$_devToken = trim($_COOKIE['turnero_token'] ?? '');
|
||||
if ($_devToken) {
|
||||
$_s = $_pdo2->prepare(
|
||||
"SELECT td.lugar_id, td.nombre, tl.tipo
|
||||
FROM turnero_dispositivos td
|
||||
JOIN turnero_lugares tl ON tl.id = td.lugar_id
|
||||
WHERE td.token = ? AND td.activo = 1 LIMIT 1"
|
||||
);
|
||||
$_s->execute([$_devToken]);
|
||||
$_disp = $_s->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
if (!$_disp) {
|
||||
$_s = $_pdo2->prepare(
|
||||
"SELECT td.lugar_id, td.nombre, tl.tipo
|
||||
FROM turnero_dispositivos td
|
||||
JOIN turnero_lugares tl ON tl.id = td.lugar_id
|
||||
WHERE td.ip = ? AND td.activo = 1 LIMIT 1"
|
||||
);
|
||||
$_s->execute([$clientIp]);
|
||||
$_disp = $_s->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
if ($_disp) {
|
||||
$_SESSION['turnero_dispositivo'] = $_disp;
|
||||
$url = $_disp['tipo'] === 'recepcion'
|
||||
? BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_disp['lugar_id']
|
||||
: BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_disp['lugar_id'];
|
||||
header('Location: ' . $url); exit;
|
||||
}
|
||||
} catch (\Throwable $_) {}
|
||||
|
||||
$roleSlug = $adminUser['role'] ?? 'admin';
|
||||
$modules = $adminUser['modules'] ?? [];
|
||||
|
||||
@@ -85,32 +51,10 @@ if ($_POST && !$loginBlocked) {
|
||||
exit;
|
||||
}
|
||||
}
|
||||
// Usuario con lugar fijo asignado (sin IP registrada)
|
||||
$_userLugarId = (int)($adminUser['turnero_lugar_id'] ?? 0);
|
||||
if ($_userLugarId && in_array('turnero', $modules, true)) {
|
||||
// Determinar si ese lugar es recepción o toma de muestras
|
||||
try {
|
||||
$_lugarTipo = Database::getInstance()->getConnection()
|
||||
->prepare("SELECT tipo FROM turnero_lugares WHERE id = ? LIMIT 1");
|
||||
$_lugarTipo->execute([$_userLugarId]);
|
||||
$_tipo = $_lugarTipo->fetchColumn() ?: 'muestras';
|
||||
} catch (\Throwable $_) { $_tipo = 'muestras'; }
|
||||
$url = $_tipo === 'recepcion'
|
||||
? BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_userLugarId
|
||||
: BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_userLugarId;
|
||||
header('Location: ' . $url); exit;
|
||||
}
|
||||
|
||||
if ($roleSlug === 'enfermero') {
|
||||
header('Location: enfermero_portal.php');
|
||||
} elseif (in_array('turnero', $modules, true)) {
|
||||
if ($roleSlug === 'bacteriologo') {
|
||||
header('Location: erp.php?m=turnero&v=dashboard');
|
||||
} elseif (in_array($roleSlug, ['superadmin', 'admin', 'supervisor'], true)) {
|
||||
header('Location: erp.php?m=turnero&v=dashboard');
|
||||
} else {
|
||||
header('Location: erp.php?m=turnero&v=recepcion');
|
||||
}
|
||||
} elseif (in_array('lab_dashboard', $modules, true)) {
|
||||
header('Location: lab_dashboard.php');
|
||||
} elseif (in_array('lab_formularios', $modules, true)) {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
if (!defined('MIGRATION_TOKEN') || ($_GET['token'] ?? '') !== MIGRATION_TOKEN) {
|
||||
http_response_code(403); die('Acceso denegado');
|
||||
}
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
|
||||
$steps = [
|
||||
"ALTER TABLE lab_pacientes ADD COLUMN IF NOT EXISTS origen VARCHAR(20) NOT NULL DEFAULT 'manual' COMMENT 'Origen: manual | lab | whatsapp' AFTER notas_admin",
|
||||
"UPDATE lab_pacientes SET origen = 'whatsapp' WHERE user_id IS NOT NULL AND origen = 'manual'",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pac_origen ON lab_pacientes (origen)",
|
||||
];
|
||||
|
||||
echo '<pre>';
|
||||
foreach ($steps as $sql) {
|
||||
echo htmlspecialchars(substr($sql, 0, 80)) . "...\n";
|
||||
try {
|
||||
$pdo->exec($sql);
|
||||
echo " ✓ OK\n\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo " ✗ ERROR: " . htmlspecialchars($e->getMessage()) . "\n\n";
|
||||
}
|
||||
}
|
||||
echo "Listo.\n</pre>";
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Agrega número de orden visible a domicilios (formato D-YYYYMMDD-NNN)
|
||||
ALTER TABLE lab_domicilios
|
||||
ADD COLUMN numero_orden VARCHAR(20) NULL DEFAULT NULL
|
||||
COMMENT 'Número de orden del domicilio. Formato D-YYYYMMDD-NNN'
|
||||
AFTER paciente_id;
|
||||
|
||||
CREATE INDEX idx_lab_domicilios_numero_orden ON lab_domicilios (numero_orden);
|
||||
@@ -1,32 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 01 — Catálogos base
|
||||
-- Crea: lab_secciones, lab_especialidades, lab_tipos_muestra
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lab_secciones (
|
||||
codigo VARCHAR(10) NOT NULL,
|
||||
nombre VARCHAR(100) NOT NULL,
|
||||
PRIMARY KEY (codigo)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Secciones del laboratorio (Hematología, Química, etc.)';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lab_especialidades (
|
||||
codigo VARCHAR(10) NOT NULL,
|
||||
nombre VARCHAR(100) NOT NULL,
|
||||
PRIMARY KEY (codigo)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Especialidades médicas — replica ESPECIALIDAD Firebird';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lab_tipos_muestra (
|
||||
codigo VARCHAR(20) NOT NULL,
|
||||
nombre VARCHAR(100) NOT NULL,
|
||||
color_hex VARCHAR(7) DEFAULT NULL COMMENT 'Color del tubo para UI (#RRGGBB)',
|
||||
requiere_ayuno TINYINT(1) NOT NULL DEFAULT 0,
|
||||
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (codigo)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Tipos de muestra derivados de EXAMEN.TIPOMUESTRA Firebird';
|
||||
@@ -1,43 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 02 — Protocolos e ítems de resultado (catálogos)
|
||||
-- Crea: lab_protocolos, lab_items_resultado
|
||||
-- Depende de: lab_secciones (LIS 01)
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lab_protocolos (
|
||||
codigo VARCHAR(20) NOT NULL,
|
||||
nombre VARCHAR(150) NOT NULL,
|
||||
cod_seccion VARCHAR(10) DEFAULT NULL,
|
||||
id_planilla VARCHAR(30) DEFAULT NULL COMMENT 'Identificador de plantilla de impresión',
|
||||
only_show_items TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (codigo),
|
||||
KEY idx_seccion (cod_seccion),
|
||||
CONSTRAINT fk_proto_seccion
|
||||
FOREIGN KEY (cod_seccion) REFERENCES lab_secciones (codigo)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Plantillas de resultado — replica PROTOCOLO Firebird';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lab_items_resultado (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
cod_protocolo VARCHAR(20) NOT NULL,
|
||||
nombre VARCHAR(150) NOT NULL,
|
||||
tipo_sexo ENUM('M','F') DEFAULT NULL COMMENT 'NULL = aplica a ambos sexos',
|
||||
tipo ENUM('N','T') DEFAULT NULL COMMENT 'N=numérico T=texto',
|
||||
medida VARCHAR(30) DEFAULT NULL,
|
||||
abreviatura VARCHAR(30) DEFAULT NULL,
|
||||
vmin_ref DECIMAL(12,4) DEFAULT NULL,
|
||||
vmax_ref DECIMAL(12,4) DEFAULT NULL,
|
||||
orden SMALLINT NOT NULL DEFAULT 0,
|
||||
formula VARCHAR(500) DEFAULT NULL COMMENT 'Fórmula de cálculo automático',
|
||||
cups_detalle VARCHAR(20) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_protocolo (cod_protocolo),
|
||||
KEY idx_orden (cod_protocolo, orden),
|
||||
CONSTRAINT fk_item_proto
|
||||
FOREIGN KEY (cod_protocolo) REFERENCES lab_protocolos (codigo)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Analitos por protocolo — replica ITEM Firebird';
|
||||
@@ -1,26 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 03 — Perfiles de examen (paquetes)
|
||||
-- Crea: lab_perfiles, lab_perfil_examenes
|
||||
-- Depende de: exam_tipos (migración 003)
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lab_perfiles (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
nombre VARCHAR(150) NOT NULL,
|
||||
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Paquetes de exámenes — replica PERFIL Firebird';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lab_perfil_examenes (
|
||||
perfil_id INT UNSIGNED NOT NULL,
|
||||
exam_tipo_id INT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (perfil_id, exam_tipo_id),
|
||||
CONSTRAINT fk_pe_perfil FOREIGN KEY (perfil_id)
|
||||
REFERENCES lab_perfiles (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_pe_exam FOREIGN KEY (exam_tipo_id)
|
||||
REFERENCES exam_tipos (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Exámenes por perfil — replica PERFIL_EXA Firebird';
|
||||
@@ -1,50 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 04 — Ampliar exam_tipos y lab_pacientes con campos legacy
|
||||
-- Depende de: lab_tipos_muestra (LIS 01), lab_protocolos (LIS 02)
|
||||
-- =============================================================
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- exam_tipos: agregar campos del EXAMEN Firebird
|
||||
-- -----------------------------------------------------------
|
||||
ALTER TABLE exam_tipos
|
||||
ADD COLUMN IF NOT EXISTS codigo_legacy VARCHAR(20) DEFAULT NULL
|
||||
COMMENT 'Código original Firebird (CODIGO). Usado para ETL y mapeos.',
|
||||
ADD COLUMN IF NOT EXISTS cups VARCHAR(20) DEFAULT NULL
|
||||
COMMENT 'Código CUPS colombiano',
|
||||
ADD COLUMN IF NOT EXISTS cod_protocolo VARCHAR(20) DEFAULT NULL
|
||||
COMMENT 'FK lab_protocolos.codigo',
|
||||
ADD COLUMN IF NOT EXISTS tipo_muestra VARCHAR(20) DEFAULT NULL
|
||||
COMMENT 'FK lab_tipos_muestra.codigo',
|
||||
ADD COLUMN IF NOT EXISTS nivel TINYINT DEFAULT NULL
|
||||
COMMENT 'Nivel de complejidad (1/2/3)',
|
||||
ADD COLUMN IF NOT EXISTS abreviatura VARCHAR(30) DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS seremite TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT '1 = se remite a laboratorio externo',
|
||||
ADD COLUMN IF NOT EXISTS serecibe VARCHAR(50) DEFAULT NULL
|
||||
COMMENT 'Nombre del laboratorio donde se recibe si seremite=1';
|
||||
|
||||
-- Índice único para buscar por código legacy durante el ETL
|
||||
ALTER TABLE exam_tipos
|
||||
ADD UNIQUE KEY IF NOT EXISTS uq_codigo_legacy (codigo_legacy);
|
||||
|
||||
-- FK suave (no FK real para facilitar carga masiva del ETL)
|
||||
ALTER TABLE exam_tipos
|
||||
ADD KEY IF NOT EXISTS idx_cod_protocolo (cod_protocolo),
|
||||
ADD KEY IF NOT EXISTS idx_tipo_muestra (tipo_muestra);
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- lab_pacientes: agregar campos del PACIENTE Firebird
|
||||
-- -----------------------------------------------------------
|
||||
ALTER TABLE lab_pacientes
|
||||
ADD COLUMN IF NOT EXISTS codigo_legacy VARCHAR(20) DEFAULT NULL
|
||||
COMMENT 'CODPAC Firebird',
|
||||
ADD COLUMN IF NOT EXISTS codetnia VARCHAR(10) DEFAULT NULL
|
||||
COMMENT 'Código de etnia (RIPS/SISPRO)',
|
||||
ADD COLUMN IF NOT EXISTS tipores VARCHAR(10) DEFAULT NULL
|
||||
COMMENT 'Tipo de residencia (RIPS/SISPRO)',
|
||||
ADD COLUMN IF NOT EXISTS ocupacion VARCHAR(100) DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS es_historico TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT '1 = migrado solo de Firebird, sin cuenta en nuevo sistema';
|
||||
|
||||
ALTER TABLE lab_pacientes
|
||||
ADD KEY IF NOT EXISTS idx_codigo_legacy (codigo_legacy);
|
||||
@@ -1,97 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 05 — Motor de precios y convenios
|
||||
-- Crea: lab_tarifas_id, lab_tarifas, lab_empresas,
|
||||
-- lab_empresa_subgrupos, lab_examenes_empresa
|
||||
-- =============================================================
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Catálogo de tarifas
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lab_tarifas_id (
|
||||
id INT NOT NULL,
|
||||
nombre VARCHAR(150) NOT NULL,
|
||||
tarifa_origen INT DEFAULT NULL
|
||||
COMMENT 'Si != NULL, esta tarifa = tarifa_origen * (1 + porcentaje/100)',
|
||||
porcentaje DECIMAL(8,4) NOT NULL DEFAULT 0
|
||||
COMMENT '0 = precios fijos, >0 = derivada porcentualmente',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_origen (tarifa_origen)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Catálogo de tarifas — replica TARIFAID Firebird';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Precios por examen y tarifa
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lab_tarifas (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
cod_examen_legacy VARCHAR(20) NOT NULL COMMENT 'CODIGO original Firebird para trazabilidad',
|
||||
exam_tipo_id INT UNSIGNED DEFAULT NULL,
|
||||
tarifa_id INT NOT NULL,
|
||||
valor DECIMAL(12,2) NOT NULL,
|
||||
recargo_urg DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
recargo_fes DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
recargo_esp DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_examen_tarifa (exam_tipo_id, tarifa_id),
|
||||
KEY idx_legacy (cod_examen_legacy),
|
||||
KEY idx_tarifa (tarifa_id),
|
||||
CONSTRAINT fk_tar_tarifa FOREIGN KEY (tarifa_id)
|
||||
REFERENCES lab_tarifas_id (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Precios por examen y tarifa — replica TARIFA Firebird';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Empresas / convenios
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lab_empresas (
|
||||
nit VARCHAR(20) NOT NULL,
|
||||
nombre VARCHAR(200) NOT NULL,
|
||||
razon_social VARCHAR(200) DEFAULT NULL,
|
||||
tarifa_id INT DEFAULT NULL,
|
||||
descuento_pct DECIMAL(8,4) NOT NULL DEFAULT 0,
|
||||
codigo_eps VARCHAR(20) DEFAULT NULL,
|
||||
tipo_usuario VARCHAR(10) DEFAULT NULL COMMENT 'Tipo usuario para facturación',
|
||||
tipo_usuario_sispro VARCHAR(10) DEFAULT NULL,
|
||||
cod_contrato VARCHAR(50) DEFAULT NULL,
|
||||
cod_tercero VARCHAR(50) DEFAULT NULL,
|
||||
centro_costo VARCHAR(50) DEFAULT NULL,
|
||||
req_autoriza TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT '1 = exige número de autorización en recepción',
|
||||
activa TINYINT(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (nit),
|
||||
KEY idx_tarifa (tarifa_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Empresas y convenios — replica EMPRESA Firebird';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Subgrupos de empresa
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lab_empresa_subgrupos (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
nit_empresa VARCHAR(20) NOT NULL,
|
||||
subgrupo VARCHAR(100) NOT NULL,
|
||||
tarifa_id INT DEFAULT NULL,
|
||||
ref_subgrupo VARCHAR(50) DEFAULT NULL,
|
||||
cod_contrato VARCHAR(50) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_empresa_sub (nit_empresa, subgrupo),
|
||||
KEY idx_tarifa (tarifa_id),
|
||||
CONSTRAINT fk_esub_empresa FOREIGN KEY (nit_empresa)
|
||||
REFERENCES lab_empresas (nit) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Subgrupos por empresa — replica EMPRESA_SUB Firebird';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Códigos de examen alternos por empresa (para interfaces)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lab_examenes_empresa (
|
||||
nit_empresa VARCHAR(20) NOT NULL,
|
||||
cod_examen_legacy VARCHAR(20) NOT NULL,
|
||||
exam_tipo_id INT UNSIGNED DEFAULT NULL,
|
||||
codigo_empresa VARCHAR(50) NOT NULL,
|
||||
PRIMARY KEY (nit_empresa, cod_examen_legacy),
|
||||
KEY idx_exam (exam_tipo_id),
|
||||
CONSTRAINT fk_ee_empresa FOREIGN KEY (nit_empresa)
|
||||
REFERENCES lab_empresas (nit) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Códigos alternos por empresa — replica EXAMEN_EMP Firebird';
|
||||
@@ -1,90 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 06 — Histórico transaccional (solo lectura post-migración)
|
||||
-- Crea: lab_recepciones, lab_relaciones, lab_pagos, lab_pagos_det
|
||||
-- Depende de: lab_pacientes, medicos, lab_empresas
|
||||
-- =============================================================
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Recepciones (equivalente a facturas del legacy)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lab_recepciones (
|
||||
id INT NOT NULL COMMENT 'IDRECEPCION original Firebird',
|
||||
cod_paciente_legacy VARCHAR(20) DEFAULT NULL,
|
||||
paciente_id INT DEFAULT NULL,
|
||||
cod_medico_legacy VARCHAR(20) DEFAULT NULL,
|
||||
medico_id INT UNSIGNED DEFAULT NULL,
|
||||
nit_empresa VARCHAR(20) DEFAULT NULL,
|
||||
subgrupo VARCHAR(100) DEFAULT NULL,
|
||||
fecha_recepcion DATE NOT NULL,
|
||||
hora_inicio TIME DEFAULT NULL,
|
||||
prefijo VARCHAR(5) DEFAULT NULL,
|
||||
num_factura INT DEFAULT NULL,
|
||||
valor_total DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
valor_desc DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
diag_ppal VARCHAR(10) DEFAULT NULL COMMENT 'Código diagnóstico CIE-10',
|
||||
tipo_usuario VARCHAR(10) DEFAULT NULL,
|
||||
autorizacion VARCHAR(50) DEFAULT NULL,
|
||||
usuario VARCHAR(50) DEFAULT NULL COMMENT 'Login del operador en Firebird',
|
||||
es_historico TINYINT(1) NOT NULL DEFAULT 1,
|
||||
migrado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_paciente (paciente_id),
|
||||
KEY idx_fecha (fecha_recepcion),
|
||||
KEY idx_empresa (nit_empresa),
|
||||
KEY idx_factura (prefijo, num_factura)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Recepciones históricas — solo lectura, migrado de Firebird';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Exámenes por recepción
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lab_relaciones (
|
||||
recepcion_id INT NOT NULL,
|
||||
cod_examen_legacy VARCHAR(20) NOT NULL,
|
||||
exam_tipo_id INT UNSIGNED DEFAULT NULL,
|
||||
precio DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
fecha_reportado DATE DEFAULT NULL,
|
||||
reportado TINYINT(1) NOT NULL DEFAULT 0,
|
||||
reportado_por VARCHAR(50) DEFAULT NULL,
|
||||
validado TINYINT(1) NOT NULL DEFAULT 0,
|
||||
usuario_valida VARCHAR(50) DEFAULT NULL,
|
||||
fecha_valida DATE DEFAULT NULL,
|
||||
PRIMARY KEY (recepcion_id, cod_examen_legacy),
|
||||
KEY idx_exam_tipo (exam_tipo_id),
|
||||
CONSTRAINT fk_rel_recepcion FOREIGN KEY (recepcion_id)
|
||||
REFERENCES lab_recepciones (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Exámenes por recepción histórica — replica RELACION Firebird';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Pagos
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lab_pagos (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
numcaja_legacy INT NOT NULL UNIQUE COMMENT 'NUMCAJA original Firebird',
|
||||
recepcion_id INT NOT NULL,
|
||||
valor DECIMAL(12,2) NOT NULL,
|
||||
fecha DATE NOT NULL,
|
||||
usuario VARCHAR(50) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_recepcion (recepcion_id),
|
||||
CONSTRAINT fk_pago_recep FOREIGN KEY (recepcion_id)
|
||||
REFERENCES lab_recepciones (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Pagos históricos — replica PAGOS Firebird';
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- Detalle de formas de pago por transacción
|
||||
-- -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS lab_pagos_det (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
pago_id INT UNSIGNED NOT NULL,
|
||||
tipo_pago VARCHAR(30) NOT NULL COMMENT 'efectivo, cheque, tarjeta, etc.',
|
||||
valor DECIMAL(12,2) NOT NULL,
|
||||
num_doc VARCHAR(50) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_pago (pago_id),
|
||||
CONSTRAINT fk_pagdet_pago FOREIGN KEY (pago_id)
|
||||
REFERENCES lab_pagos (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Detalle de formas de pago — replica PAGOS_DET Firebird';
|
||||
@@ -1,28 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 07 — Tracking de muestras por turnero (solicitud)
|
||||
-- Crea: turnero_muestras
|
||||
-- Depende de: turnero_solicitudes (003), lab_tipos_muestra (LIS 01)
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS turnero_muestras (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
solicitud_id INT UNSIGNED NOT NULL
|
||||
COMMENT 'FK turnero_solicitudes.id',
|
||||
tipo_muestra VARCHAR(20) NOT NULL
|
||||
COMMENT 'FK lab_tipos_muestra.codigo (ej: SANGRE_VENOSA, ORINA)',
|
||||
estado ENUM('pendiente','recibida','rechazada')
|
||||
NOT NULL DEFAULT 'pendiente',
|
||||
motivo_rechazo VARCHAR(200) DEFAULT NULL
|
||||
COMMENT 'Razón de rechazo (hemólisis, coagulado, volumen insuficiente…)',
|
||||
recibida_por INT DEFAULT NULL
|
||||
COMMENT 'FK admin_users.id — quién marcó recibida',
|
||||
recibida_at DATETIME DEFAULT NULL,
|
||||
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_solicitud_tipo (solicitud_id, tipo_muestra),
|
||||
KEY idx_estado (estado),
|
||||
KEY idx_solicitud (solicitud_id),
|
||||
CONSTRAINT fk_tm_solicitud FOREIGN KEY (solicitud_id)
|
||||
REFERENCES turnero_solicitudes (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Estado de muestras físicas por turno — solo lugar toma de muestras';
|
||||
@@ -1,186 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 08 — Vistas del sistema
|
||||
-- Todas usan CREATE OR REPLACE para poder re-ejecutar sin error.
|
||||
-- =============================================================
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- v_muestras_hoy
|
||||
-- Muestras del día en el turnero (estado en tiempo real).
|
||||
-- Usada por: dashboard, widget lugar.php
|
||||
-- -----------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW v_muestras_hoy AS
|
||||
SELECT
|
||||
tm.id,
|
||||
tm.solicitud_id,
|
||||
tm.tipo_muestra,
|
||||
COALESCE(lt.nombre, tm.tipo_muestra) AS tipo_muestra_label,
|
||||
lt.color_hex AS tipo_muestra_color,
|
||||
tm.estado,
|
||||
tm.motivo_rechazo,
|
||||
tm.recibida_at,
|
||||
ts.turno_id,
|
||||
tt.codigo AS turno_codigo,
|
||||
ts.lugar_id,
|
||||
tl.nombre AS lugar_nombre,
|
||||
ts.paciente_id,
|
||||
lp.nombre_completo AS paciente_nombre,
|
||||
lp.numero_documento AS paciente_documento,
|
||||
DATE(tt.creado_at) AS fecha
|
||||
FROM turnero_muestras tm
|
||||
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
||||
JOIN turnero_turnos tt ON tt.id = ts.turno_id
|
||||
JOIN turnero_lugares tl ON tl.id = ts.lugar_id
|
||||
LEFT JOIN lab_pacientes lp ON lp.id = ts.paciente_id
|
||||
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- v_muestras_pendientes_hoy
|
||||
-- Solo las que faltan recibir hoy.
|
||||
-- Usada por: contador en dashboard, alerta visual
|
||||
-- -----------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW v_muestras_pendientes_hoy AS
|
||||
SELECT *
|
||||
FROM v_muestras_hoy
|
||||
WHERE estado = 'pendiente'
|
||||
AND fecha = CURDATE();
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- v_recepcion_completa
|
||||
-- Histórico Firebird con todas las FK resueltas.
|
||||
-- Usada por: módulo de consulta histórica (solo lectura)
|
||||
-- -----------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW v_recepcion_completa AS
|
||||
SELECT
|
||||
r.id,
|
||||
r.fecha_recepcion,
|
||||
r.hora_inicio,
|
||||
r.prefijo,
|
||||
r.num_factura,
|
||||
CONCAT(COALESCE(r.prefijo,''), '-',
|
||||
LPAD(COALESCE(r.num_factura, 0), 6, '0')) AS factura,
|
||||
r.valor_total,
|
||||
r.valor_desc,
|
||||
(r.valor_total - r.valor_desc) AS valor_neto,
|
||||
r.paciente_id,
|
||||
r.cod_paciente_legacy,
|
||||
COALESCE(lp.nombre_completo, r.cod_paciente_legacy) AS paciente_nombre,
|
||||
lp.numero_documento AS paciente_documento,
|
||||
lp.tipo_documento AS paciente_tipo_doc,
|
||||
r.medico_id,
|
||||
r.cod_medico_legacy,
|
||||
CONCAT(COALESCE(m.nombres,''), ' ', COALESCE(m.apellidos,'')) AS medico_nombre,
|
||||
m.cod_especialidad AS medico_especialidad,
|
||||
r.nit_empresa,
|
||||
COALESCE(e.nombre, r.nit_empresa) AS empresa_nombre,
|
||||
r.subgrupo,
|
||||
r.diag_ppal,
|
||||
r.tipo_usuario,
|
||||
r.autorizacion,
|
||||
r.usuario
|
||||
FROM lab_recepciones r
|
||||
LEFT JOIN lab_pacientes lp ON lp.id = r.paciente_id
|
||||
LEFT JOIN medicos m ON m.id = r.medico_id
|
||||
LEFT JOIN lab_empresas e ON e.nit = r.nit_empresa;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- v_relacion_completa
|
||||
-- Exámenes por recepción con nombre resuelto.
|
||||
-- -----------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW v_relacion_completa AS
|
||||
SELECT
|
||||
lr.recepcion_id,
|
||||
r.fecha_recepcion,
|
||||
r.prefijo,
|
||||
r.num_factura,
|
||||
lr.cod_examen_legacy,
|
||||
lr.exam_tipo_id,
|
||||
et.nombre AS examen_nombre,
|
||||
et.categoria AS examen_categoria,
|
||||
et.tipo_muestra AS tipo_muestra,
|
||||
lr.precio,
|
||||
lr.reportado,
|
||||
lr.fecha_reportado,
|
||||
lr.reportado_por,
|
||||
lr.validado,
|
||||
lr.usuario_valida,
|
||||
lr.fecha_valida
|
||||
FROM lab_relaciones lr
|
||||
JOIN lab_recepciones r ON r.id = lr.recepcion_id
|
||||
LEFT JOIN exam_tipos et ON et.id = lr.exam_tipo_id;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- v_examen_precio
|
||||
-- Precio efectivo de cada examen en cada tarifa.
|
||||
-- Resuelve tarifas derivadas por porcentaje.
|
||||
-- Usada por: motor de precios en nueva recepción
|
||||
-- -----------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW v_examen_precio AS
|
||||
SELECT
|
||||
et.id AS exam_tipo_id,
|
||||
et.codigo,
|
||||
et.codigo_legacy,
|
||||
et.nombre AS examen_nombre,
|
||||
et.categoria,
|
||||
et.tipo_muestra,
|
||||
ti.id AS tarifa_id,
|
||||
ti.nombre AS tarifa_nombre,
|
||||
ti.porcentaje,
|
||||
ti.tarifa_origen,
|
||||
lt.valor AS valor_almacenado,
|
||||
CASE
|
||||
WHEN ti.porcentaje > 0 AND ti.tarifa_origen IS NOT NULL
|
||||
AND lt_base.valor IS NOT NULL
|
||||
THEN ROUND(lt_base.valor * (1 + ti.porcentaje / 100), 0)
|
||||
ELSE lt.valor
|
||||
END AS valor_efectivo,
|
||||
lt.recargo_urg,
|
||||
lt.recargo_fes,
|
||||
lt.recargo_esp
|
||||
FROM exam_tipos et
|
||||
JOIN lab_tarifas lt ON lt.exam_tipo_id = et.id
|
||||
JOIN lab_tarifas_id ti ON ti.id = lt.tarifa_id
|
||||
LEFT JOIN lab_tarifas lt_base ON lt_base.exam_tipo_id = et.id
|
||||
AND lt_base.tarifa_id = ti.tarifa_origen;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- v_paciente_resumen
|
||||
-- Vista unificada: pacientes del nuevo sistema + migrados.
|
||||
-- -----------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW v_paciente_resumen AS
|
||||
SELECT
|
||||
id,
|
||||
nombre_completo,
|
||||
tipo_documento,
|
||||
numero_documento,
|
||||
telefono,
|
||||
email,
|
||||
fecha_nacimiento,
|
||||
genero,
|
||||
ciudad,
|
||||
eps,
|
||||
es_historico,
|
||||
codigo_legacy,
|
||||
created_at
|
||||
FROM lab_pacientes
|
||||
WHERE is_active = 1;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- v_turno_muestras_estado
|
||||
-- Estado agregado de muestras por turno (para cola y dashboard).
|
||||
-- -----------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW v_turno_muestras_estado AS
|
||||
SELECT
|
||||
ts.turno_id,
|
||||
COUNT(*) AS total_muestras,
|
||||
SUM(tm.estado = 'pendiente') AS pendientes,
|
||||
SUM(tm.estado = 'recibida') AS recibidas,
|
||||
SUM(tm.estado = 'rechazada') AS rechazadas,
|
||||
CASE
|
||||
WHEN SUM(tm.estado = 'pendiente') = 0 THEN 'completo'
|
||||
WHEN SUM(tm.estado = 'recibida') = 0 AND SUM(tm.estado = 'rechazada') = 0
|
||||
THEN 'sin_recibir'
|
||||
ELSE 'parcial'
|
||||
END AS estado_global
|
||||
FROM turnero_muestras tm
|
||||
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
||||
GROUP BY ts.turno_id;
|
||||
@@ -1,17 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 09 — Registra módulo lab_empresas en system_modules
|
||||
-- y agrega permiso por defecto para el rol admin.
|
||||
-- Seguro para re-ejecutar (INSERT IGNORE).
|
||||
-- =============================================================
|
||||
|
||||
INSERT IGNORE INTO system_modules
|
||||
(slug, name, icon, category, route, is_active, sort_order, oleada, description)
|
||||
VALUES
|
||||
('lab_empresas', 'Empresas y Convenios', 'fas fa-building', 'clinico',
|
||||
'/erp.php?m=lab_empresas&v=index', 1, 65, 2,
|
||||
'Gestión de empresas, EPS, convenios, subgrupos y catálogo de tarifas');
|
||||
|
||||
-- Permiso automático para rol admin (role_id = 1)
|
||||
INSERT IGNORE INTO role_modules (role_id, module_slug)
|
||||
SELECT 1, 'lab_empresas'
|
||||
WHERE EXISTS (SELECT 1 FROM roles WHERE id = 1);
|
||||
@@ -1,16 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 10 — Extiende turnero_solicitudes con campos de empresa/convenio
|
||||
-- Todos DEFAULT NULL para no romper datos existentes.
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE turnero_solicitudes
|
||||
ADD COLUMN IF NOT EXISTS nit_empresa VARCHAR(20) DEFAULT NULL
|
||||
COMMENT 'FK lab_empresas.nit — empresa/EPS del paciente',
|
||||
ADD COLUMN IF NOT EXISTS subgrupo_id INT UNSIGNED DEFAULT NULL
|
||||
COMMENT 'FK lab_empresa_subgrupos.id',
|
||||
ADD COLUMN IF NOT EXISTS autorizacion VARCHAR(100) DEFAULT NULL
|
||||
COMMENT 'Número de autorización EPS',
|
||||
ADD COLUMN IF NOT EXISTS diag_ppal VARCHAR(20) DEFAULT NULL
|
||||
COMMENT 'Diagnóstico principal CIE-10',
|
||||
ADD COLUMN IF NOT EXISTS items_precio JSON DEFAULT NULL
|
||||
COMMENT 'Snapshot de precios al momento de la recepción';
|
||||
@@ -1,25 +0,0 @@
|
||||
-- =============================================================
|
||||
-- LIS 11 — Habilita lab_recepciones para registros nuevos (no históricos)
|
||||
--
|
||||
-- El esquema original fue diseñado SOLO para histórico Firebird:
|
||||
-- · id INT NOT NULL (no AUTO_INCREMENT) — el ID venía de Firebird
|
||||
-- · lab_pagos.numcaja_legacy NOT NULL UNIQUE — también era de Firebird
|
||||
--
|
||||
-- Aquí los ajustamos para aceptar registros del nuevo sistema
|
||||
-- sin tocar los datos históricos migrados.
|
||||
-- =============================================================
|
||||
|
||||
-- 1. Hacer id AUTO_INCREMENT (MySQL usará MAX(id)+1 como siguiente valor,
|
||||
-- así los registros nuevos nunca colisionan con los históricos de Firebird).
|
||||
ALTER TABLE lab_recepciones
|
||||
MODIFY COLUMN id INT NOT NULL AUTO_INCREMENT;
|
||||
|
||||
-- 2. Enlace de vuelta a la solicitud del turnero
|
||||
ALTER TABLE lab_recepciones
|
||||
ADD COLUMN IF NOT EXISTS solicitud_id INT UNSIGNED DEFAULT NULL
|
||||
COMMENT 'FK turnero_solicitudes.id — NULL para registros históricos Firebird';
|
||||
|
||||
-- 3. numcaja_legacy era NOT NULL UNIQUE (campo obligatorio en Firebird).
|
||||
-- Los registros nuevos no tienen NUMCAJA → lo hacemos nullable.
|
||||
ALTER TABLE lab_pagos
|
||||
MODIFY COLUMN numcaja_legacy INT DEFAULT NULL;
|
||||
@@ -1,7 +0,0 @@
|
||||
ALTER TABLE lab_domicilios
|
||||
ADD COLUMN IF NOT EXISTS numero_orden VARCHAR(30) DEFAULT NULL
|
||||
COMMENT 'Número de orden de domicilio (ej: D-2026-001)';
|
||||
|
||||
ALTER TABLE turnero_solicitudes
|
||||
ADD COLUMN IF NOT EXISTS numero_orden VARCHAR(30) DEFAULT NULL
|
||||
COMMENT 'Número de orden de la solicitud del turnero';
|
||||
@@ -1,7 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260707_admin_users_firma
|
||||
-- Firma pre-guardada del profesional para firma en 1 clic
|
||||
-- ============================================================
|
||||
ALTER TABLE admin_users
|
||||
ADD COLUMN IF NOT EXISTS firma_svg MEDIUMTEXT DEFAULT NULL
|
||||
COMMENT 'Firma pre-guardada del profesional (data URL PNG)';
|
||||
@@ -1,107 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260707_formulario_vih_turnero
|
||||
-- Clon de F-LAB-05 VIH para uso en turnero.
|
||||
--
|
||||
-- Problema del original (id=1):
|
||||
-- · Esquema sin campos tipo "firma" ni "firma_profesional"
|
||||
-- · Paciente firmaba por fallback global (canvas genérico al pie)
|
||||
-- · Profesional NUNCA podía firmar (requiere $_soloFirmaPro=true
|
||||
-- o campo firma_profesional en esquema — ninguno se cumplía)
|
||||
-- · doc_color vacío, requiere_firma=0
|
||||
--
|
||||
-- Esta versión corrige el esquema agregando:
|
||||
-- · Separador + campo texto para responsable (menores/incapaces)
|
||||
-- · campo tipo="firma" → firma inline del paciente
|
||||
-- · campo tipo="firma_profesional" → 1-clic para el profesional
|
||||
-- ============================================================
|
||||
|
||||
SET @esquema_vih = '[
|
||||
{
|
||||
"id": "_8h4v4jg",
|
||||
"tipo": "parrafo",
|
||||
"contenido": "Autorización voluntaria para realizar la prueba presuntiva para VIH (Decreto 1543/97 del Ministerio de Protección Social por el cual se reglamentan los mecanismos de prevención, diagnóstico, manejo y reporte epidemiológico de la infección por VIH)",
|
||||
"flujoLibre": false
|
||||
},
|
||||
{
|
||||
"id": "_z7pv612",
|
||||
"tipo": "fecha_hoy",
|
||||
"label": "Fecha",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"id": "_afsycwa",
|
||||
"tipo": "parrafo",
|
||||
"contenido": "Qué es el síndrome de inmunodeficiencia adquirida (SIDA)? \nEs una enfermedad producida por un virus conocido como el virus de inmunodeficiencia Humana (VIH), el cual infecta y destruye las células del sistema inmune, originando una falla progresiva y grave en los sistemas de defensa del organismo el cual queda expuesto a la infección y ciertos tipos de tumores. \n\n¿Cómo se adquiere la enfermedad? \nLa enfermedad se adquiere principalmente por contacto sexual con personas infectadas con el VIH. Por exposición a la sangre y a ciertos productos derivados de la misma contaminados con el virus. Además, durante el embarazo, las madres infectadas con el virus de inmunodeficiencia humana pueden transmitir la infección al feto a través de la placenta. \n\n¿Cómo se hace el diagnóstico de la infección? El diagnóstico se hace mediante una prueba de sangre que busca anticuerpos producidos por el organismo contra el virus. Existen dos clases de pruebas de laboratorio. Presuntivas, que pueden indicar una posible infección, y las pruebas confirmatorias, las cuales se hacen únicamente en caso de que la prueba presuntiva de positiva. \n\n¿Cuál es el procedimiento que el laboratorio debe realizar en el análisis de la prueba? El laboratorio procesa la muestra y en caso de un resultado presuntamente positivo o inconcluyente, realiza un segundo examen con otra muestra. En caso de que la segunda muestra también arroje un resultado presuntamente positivo, el resultado de la prueba se reporta como reactivo. En caso de que la segunda muestra no confirme los resultados de la primera muestra, las dos muestras se envían a un tercer laboratorio, con el fin de que sea procesada en él. Los resultados de este tercer laboratorio se toman como definitivos para decidir el reporte como Reactivo o Negativo. En todo caso, los costos por estas pruebas son asumidos por cuenta del laboratorio. \n\n¿Cómo se debe interpretar el resultado de la prueba? La prueba inicial, como ya se anotó, es apenas una prueba presuntiva, y por lo tanto, el hecho de salir reactiva no implica que usted tenga SIDA, o esté infectado por el virus. Este resultado debe ser confirmado mediante una prueba llamada Western Blot, con el fin de eliminar posibles falsos positivos de la prueba presuntiva. Para este segundo examen es importante tomar una nueva muestra y procesarla asumiendo usted los costos. Aún si esta segunda prueba confirma la presencia de anticuerpos para el VIH, esto no significa que usted tiene SIDA, pues existe un período de la enfermedad, controlable, en el cual los pacientes tienen anticuerpos contra el VIH, pero no tienen síntomas de la enfermedad, y pueden, inclusive, no desarrollar jamás la enfermedad. Lo que es muy urgente, es consultar con un médico para que se determine el estado de su enfermedad, y se inicie el tratamiento apropiado. \n\nEl resultado será entregado personalmente, previa identificación. ",
|
||||
"flujoLibre": false
|
||||
},
|
||||
{
|
||||
"id": "_wsqqoiu",
|
||||
"tipo": "parrafo_inline",
|
||||
"contenido": "Yo, {nombre_completo}, con N.º de identificación {numero_documento}, declaro que fui informado (a) sobre el examen de anticuerpos contra el VIH que me será practicado el día de hoy, he recibido asesoría, me han explicado en que consiste; el procedimiento y sus implicaciones en mi vida, y la confidencialidad con la que se manejara las información que he dado y que se obtendrá. YO COMPRENDO Y AUTORIZO LA REALIZACIÓN DE LA PRUEBA DE FORMA LIBRE Y ESPONTÁNEA, en el Laboratorio Clínico Ximena Caicedo Empresa Unipersonal."
|
||||
},
|
||||
{
|
||||
"id": "_vihsep1",
|
||||
"tipo": "separador",
|
||||
"label": "AUTORIZACIÓN Y FIRMA"
|
||||
},
|
||||
{
|
||||
"id": "_vihresp",
|
||||
"tipo": "texto",
|
||||
"label": "En caso de menor o incapacitado: nombre y parentesco del responsable",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"id": "_vihfpac",
|
||||
"tipo": "firma",
|
||||
"label": "Firma del paciente / responsable",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "_vihsep2",
|
||||
"tipo": "separador",
|
||||
"label": "USO EXCLUSIVO DEL PROFESIONAL"
|
||||
},
|
||||
{
|
||||
"id": "_vihfpro",
|
||||
"tipo": "firma_profesional",
|
||||
"label": "Firma del profesional de salud"
|
||||
}
|
||||
]';
|
||||
|
||||
INSERT INTO lab_formularios (
|
||||
nombre,
|
||||
descripcion,
|
||||
categoria,
|
||||
esquema,
|
||||
permite_firma,
|
||||
requiere_firma,
|
||||
doc_encabezado,
|
||||
doc_subtitulo,
|
||||
doc_color,
|
||||
tipo,
|
||||
is_principal,
|
||||
is_active,
|
||||
version,
|
||||
creado_por
|
||||
) VALUES (
|
||||
'CONSENTIMIENTO INFORMADO VIH F-LAB-05 V.5',
|
||||
'Autorización voluntaria para prueba presuntiva de VIH — uso turnero',
|
||||
'consentimiento',
|
||||
@esquema_vih,
|
||||
1,
|
||||
1,
|
||||
'XIMENA CAICEDO G. E.U',
|
||||
'Laboratorio Hematológico',
|
||||
'#a0a59c',
|
||||
'consentimiento',
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
1
|
||||
);
|
||||
|
||||
-- Asignar a todos los puestos de toma de muestras activos
|
||||
INSERT INTO turnero_lugar_consentimientos (lugar_id, formulario_id)
|
||||
SELECT id, LAST_INSERT_ID()
|
||||
FROM turnero_lugares
|
||||
WHERE tipo = 'muestras' AND activo = 1;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Separar consentimientos por examen vs por lugar destino
|
||||
-- Los creados desde turnero_lugar_consentimientos llevan origen_lugar_id = lugar_id de la estación
|
||||
-- Los creados desde exam_tipo_consentimientos quedan con origen_lugar_id = NULL
|
||||
ALTER TABLE turnero_consentimientos
|
||||
ADD COLUMN IF NOT EXISTS origen_lugar_id INT NULL DEFAULT NULL;
|
||||
@@ -1,28 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260707_turnero_dispositivos
|
||||
-- Mapeo IP fija → lugar para tablets del turnero.
|
||||
-- Cada tablet solo puede acceder a su lugar asignado.
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS turnero_dispositivos (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
ip VARCHAR(45) NOT NULL UNIQUE,
|
||||
lugar_id INT NOT NULL,
|
||||
nombre VARCHAR(100) NOT NULL,
|
||||
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||
INDEX idx_ip (ip),
|
||||
INDEX idx_lugar (lugar_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO turnero_dispositivos (ip, lugar_id, nombre) VALUES
|
||||
('192.168.21.61', 1, 'Toma de Muestras 1'),
|
||||
('192.168.21.62', 2, 'Toma de Muestras 2'),
|
||||
('192.168.21.63', 13, 'Toma de Muestras 3'),
|
||||
('192.168.21.64', 14, 'Toma de Muestras 4'),
|
||||
('192.168.21.65', 15, 'Toma de Muestras 5'),
|
||||
('192.168.21.66', 16, 'Toma de Muestras 6'),
|
||||
('192.168.21.67', 17, 'Toma de Muestras 7'),
|
||||
('192.168.21.68', 18, 'Toma de Muestras 8'),
|
||||
('192.168.0.221', 10, 'Recepción 1 - Vianny Ortega'),
|
||||
('192.168.0.29', 11, 'Recepción 2 - Angel Wilches'),
|
||||
('192.168.0.225', 19, 'Recepción 3 - Gina Gomez')
|
||||
ON DUPLICATE KEY UPDATE lugar_id = VALUES(lugar_id), nombre = VALUES(nombre);
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Restricción de usuario a un lugar específico del turnero.
|
||||
-- NULL = acceso libre (admin, recepcionista, etc.)
|
||||
-- Valor = solo puede operar en ese lugar (ej. bacteriólogo asignado a Toma 1)
|
||||
ALTER TABLE admin_users
|
||||
ADD COLUMN IF NOT EXISTS turnero_lugar_id INT UNSIGNED DEFAULT NULL
|
||||
COMMENT 'FK turnero_lugares.id — si != NULL el usuario solo opera en ese lugar';
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Token único por navegador/dispositivo para restricción sin depender de IP pública.
|
||||
-- NULL = dispositivo registrado solo por IP (compatibilidad hacia atrás).
|
||||
ALTER TABLE turnero_dispositivos
|
||||
ADD COLUMN IF NOT EXISTS token VARCHAR(64) DEFAULT NULL,
|
||||
ADD UNIQUE KEY IF NOT EXISTS uq_dispositivo_token (token);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Migration: 20260709_lab_pacientes_origen
|
||||
-- Agrega columna 'origen' para identificar pacientes importados desde RIPS
|
||||
-- vs creados manualmente o desde WhatsApp
|
||||
|
||||
ALTER TABLE `lab_pacientes`
|
||||
ADD COLUMN `origen` VARCHAR(20) NOT NULL DEFAULT 'manual'
|
||||
COMMENT 'Origen del registro: manual | lab | whatsapp'
|
||||
AFTER `notas_admin`;
|
||||
|
||||
-- Los pacientes con user_id ya vinculado se marcan como whatsapp
|
||||
UPDATE `lab_pacientes` SET `origen` = 'whatsapp' WHERE `user_id` IS NOT NULL;
|
||||
|
||||
CREATE INDEX `idx_origen` ON `lab_pacientes` (`origen`);
|
||||
@@ -1,25 +0,0 @@
|
||||
-- lab_eps: catálogo de EPS / aseguradoras
|
||||
CREATE TABLE IF NOT EXISTS lab_eps (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
nombre VARCHAR(120) NOT NULL UNIQUE,
|
||||
activa TINYINT(1) NOT NULL DEFAULT 1,
|
||||
orden SMALLINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT IGNORE INTO lab_eps (nombre)
|
||||
SELECT DISTINCT TRIM(eps)
|
||||
FROM lab_pacientes
|
||||
WHERE eps IS NOT NULL AND TRIM(eps) != '';
|
||||
|
||||
-- lab_ciudades: catálogo de ciudades de pacientes
|
||||
CREATE TABLE IF NOT EXISTS lab_ciudades (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
nombre VARCHAR(100) NOT NULL UNIQUE,
|
||||
activa TINYINT(1) NOT NULL DEFAULT 1,
|
||||
orden SMALLINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT IGNORE INTO lab_ciudades (nombre)
|
||||
SELECT DISTINCT TRIM(ciudad)
|
||||
FROM lab_pacientes
|
||||
WHERE ciudad IS NOT NULL AND TRIM(ciudad) != '';
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE turnero_solicitudes
|
||||
ADD COLUMN IF NOT EXISTS numero_recibo VARCHAR(40) NULL DEFAULT NULL AFTER metodo_pago;
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Cache de exámenes enviados desde RIPS Manager junto con la ingesta de pacientes.
|
||||
-- Se usa en get_examenes_rips.php para evitar el round-trip a RIPS cuando ya vienen precargados.
|
||||
CREATE TABLE IF NOT EXISTS rips_examenes_pendientes (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
numero_documento VARCHAR(30) NOT NULL,
|
||||
datos JSON NOT NULL,
|
||||
recepcion_id INT DEFAULT NULL,
|
||||
hora_recepcion VARCHAR(20) DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_doc (numero_documento),
|
||||
INDEX idx_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Migración: tabla CIE-10 diagnósticos desde Firebird
|
||||
CREATE TABLE IF NOT EXISTS cie10_diagnosticos (
|
||||
cod_diag VARCHAR(10) NOT NULL,
|
||||
concepto VARCHAR(500) NOT NULL,
|
||||
PRIMARY KEY (cod_diag)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -1,44 +0,0 @@
|
||||
-- 20260803 — Cambios de esquema del turnero aplicados el 2026-08-03
|
||||
-- Ya ejecutados en producción; este archivo los deja registrados para
|
||||
-- reconstruir el esquema desde cero o replicarlo en otro entorno.
|
||||
-- Todos usan IF NOT EXISTS para ser idempotentes.
|
||||
|
||||
-- 1. Cédula del profesional firmante (antes solo existía para enfermeros
|
||||
-- vía lab_enfermeras; bacteriólogos y recepcionistas no tenían dónde).
|
||||
ALTER TABLE admin_users
|
||||
ADD COLUMN IF NOT EXISTS cedula VARCHAR(30) NULL AFTER cargo;
|
||||
|
||||
-- Poblar desde el username cuando es numérico (así estaban registrados
|
||||
-- bacteriólogos, recepcionistas y superadmin).
|
||||
UPDATE admin_users SET cedula = username
|
||||
WHERE cedula IS NULL AND username REGEXP '^[0-9]{5,15}$' AND is_active = 1;
|
||||
|
||||
-- 2. Quién generó cada consentimiento del turnero. Sin esto el pie
|
||||
-- "Enviado por" mostraba al usuario que estuviera viendo el documento.
|
||||
ALTER TABLE turnero_consentimientos
|
||||
ADD COLUMN IF NOT EXISTS creado_por INT NULL AFTER estado;
|
||||
|
||||
-- 3. En qué turno se recibió una muestra que quedó pendiente de otra visita.
|
||||
-- El turno original no se modifica; esto solo permite vincularlos.
|
||||
ALTER TABLE turnero_muestras
|
||||
ADD COLUMN IF NOT EXISTS recibida_en_turno_id INT UNSIGNED NULL AFTER recibida_at;
|
||||
|
||||
-- 4. Playlist de la Pantalla TV (reemplaza la clave única turnero_tv_video
|
||||
-- de lab_config por una lista ordenable de videos e imágenes).
|
||||
CREATE TABLE IF NOT EXISTS turnero_tv_media (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tipo ENUM('video','imagen') NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
orden INT NOT NULL DEFAULT 0,
|
||||
duracion_segundos INT NOT NULL DEFAULT 8,
|
||||
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_orden (activo, orden)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrar el video único que existía en lab_config como primer ítem.
|
||||
INSERT INTO turnero_tv_media (tipo, url, orden, duracion_segundos, activo)
|
||||
SELECT 'video', valor, 0, 8, 1 FROM lab_config
|
||||
WHERE clave = 'turnero_tv_video' AND valor <> ''
|
||||
AND NOT EXISTS (SELECT 1 FROM turnero_tv_media);
|
||||
@@ -1,19 +0,0 @@
|
||||
-- 20260811_bsuid_identidad_whatsapp.sql
|
||||
--
|
||||
-- Meta desplegó los nombres de usuario de WhatsApp: quien oculta su teléfono
|
||||
-- llega al webhook sin `from` ni `wa_id`, identificado solo por su BSUID
|
||||
-- (identificador de usuario por empresa), con la forma "CO.1761088155094242".
|
||||
--
|
||||
-- Ese identificador ocupa el lugar del teléfono en el flujo del bot, pero no
|
||||
-- cabe en varchar(20): los BSUID de Meta llegan a 23 caracteres, así que se
|
||||
-- truncarían en silencio y la persona quedaría imposible de responder.
|
||||
--
|
||||
-- Se amplían a varchar(32) las cuatro tablas por donde circula.
|
||||
-- Las vistas (scheduled_messages_view, v_paciente_resumen) heredan el tipo.
|
||||
-- lab_pacientes.telefono se deja igual: ahí va el teléfono real del paciente,
|
||||
-- y de estas personas justamente no lo tenemos.
|
||||
|
||||
ALTER TABLE users MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE user_states MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE terms_acceptance MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE file_requests MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- 20260811_bsuid_mapa_identidad.sql
|
||||
--
|
||||
-- Segunda parte del cambio de identidad de WhatsApp (ver 20260811_bsuid_identidad_whatsapp.sql).
|
||||
--
|
||||
-- Meta manda el BSUID en TODOS los webhooks de mensaje, también en los que aún
|
||||
-- traen teléfono. Eso permite guardar la equivalencia BSUID↔teléfono mientras
|
||||
-- la persona todavía muestra su número, de modo que el día que lo oculte
|
||||
-- siga siendo reconocible: ya sabemos quién es.
|
||||
--
|
||||
-- El histórico de webhook_logs tiene 6.423 equivalencias que se cargan con
|
||||
-- scripts/backfill_bsuid.php.
|
||||
--
|
||||
-- Se guarda en una columna aparte y no en phone_number porque son dos cosas
|
||||
-- distintas: el BSUID identifica, el teléfono además sirve para cruzar con el
|
||||
-- paciente y el turnero. Mezclarlos rompería esos cruces.
|
||||
|
||||
ALTER TABLE users ADD COLUMN bsuid VARCHAR(32) NULL DEFAULT NULL COMMENT 'Identificador de usuario por empresa (Meta). Presente aunque la persona oculte su teléfono.' AFTER phone_number;
|
||||
|
||||
-- Único: un BSUID identifica a una sola persona dentro del portafolio.
|
||||
-- Admite varios NULL, que es el caso de todos los usuarios ya existentes.
|
||||
ALTER TABLE users ADD UNIQUE KEY uk_users_bsuid (bsuid);
|
||||
@@ -1,24 +0,0 @@
|
||||
-- 20260811_bsuid_pedir_contacto.sql
|
||||
--
|
||||
-- Tercera parte del cambio de identidad de WhatsApp.
|
||||
--
|
||||
-- A quien oculta su teléfono se le puede pedir con el botón request_contact_info.
|
||||
-- Se registra cuándo se le pidió para no volver a insistirle: pedirle los datos
|
||||
-- una vez es razonable, repetírselo en cada trámite es acoso.
|
||||
--
|
||||
-- Queda NULL para todo el mundo; solo se llena cuando efectivamente se pide.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN contacto_pedido_at DATETIME NULL DEFAULT NULL
|
||||
COMMENT 'Cuándo se le pidió el teléfono por el botón de WhatsApp. NULL = nunca.'
|
||||
AFTER bsuid;
|
||||
|
||||
-- Texto editable desde configuración, para que el laboratorio ajuste el mensaje
|
||||
-- sin tocar código. Si la fila ya existe, se respeta lo que haya.
|
||||
INSERT INTO system_config (config_key, config_value, description)
|
||||
VALUES (
|
||||
'whatsapp_texto_pedir_contacto',
|
||||
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?',
|
||||
'Mensaje del botón que pide el teléfono a quien lo tiene oculto en WhatsApp'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE config_key = config_key;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Estaciones accesibles desde equipos asignados a otra estación.
|
||||
-- Pediatría y Ginecología se atienden desde cualquier puesto, pero el bloqueo
|
||||
-- por IP o token devolvía al equipo a su estación sin avisar.
|
||||
-- Idempotente: puede correrse más de una vez.
|
||||
|
||||
ALTER TABLE turnero_lugares
|
||||
ADD COLUMN IF NOT EXISTS acceso_libre TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT 'Accesible desde equipos asignados a otra estación';
|
||||
|
||||
UPDATE turnero_lugares SET acceso_libre = 1
|
||||
WHERE nombre IN ('Pediatria', 'Ginecologia') AND acceso_libre = 0;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Rol de Calidad: consulta de turnos y tiempos de atención.
|
||||
-- Solo lectura, y dentro del turnero solo dashboard e historial.
|
||||
-- Incluye formularios: la coordinación del SIG administra los documentos
|
||||
-- además de revisar los tiempos del proceso.
|
||||
-- Idempotente: puede correrse más de una vez.
|
||||
|
||||
INSERT INTO roles (name, slug, description, color, home_page, is_system)
|
||||
SELECT 'Calidad', 'calidad',
|
||||
'Consulta de turnos y tiempos de atención. Solo lectura: dashboard e historial.',
|
||||
'#7c3aed', '/erp.php?m=turnero&v=dashboard', 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE slug = 'calidad');
|
||||
|
||||
INSERT INTO role_modules (role_id, module_slug, permission, can_view, can_create, can_edit, can_delete, can_export)
|
||||
SELECT r.id, m.slug, 'read', 1, 0, 0, 0, IF(m.slug = 'turnero', 1, 0)
|
||||
FROM roles r
|
||||
JOIN (SELECT 'turnero' AS slug UNION ALL SELECT 'soporte'
|
||||
UNION ALL SELECT 'lab_formularios') m
|
||||
WHERE r.slug = 'calidad'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_modules rm WHERE rm.role_id = r.id AND rm.module_slug = m.slug
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
config.local.php
|
||||
etl_*.log
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Configuración de conexiones para el ETL Firebird → MySQL
|
||||
*
|
||||
* Antes de ejecutar:
|
||||
* 1. Copiar este archivo como config.local.php (está en .gitignore)
|
||||
* 2. Llenar los valores reales
|
||||
* 3. Ejecutar: php run_etl.php
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
// ── Firebird (origen) ────────────────────────────────────────────────
|
||||
'firebird' => [
|
||||
// DSN para PDO: "firebird:dbname=HOST:RUTA_AL_FDB;charset=WIN1252"
|
||||
// Si el FDB está en la misma máquina: "firebird:dbname=localhost:/opt/firebird/data/DBLAB.FDB"
|
||||
// Si es una ruta Windows remota: "firebird:dbname=192.168.1.10:C:/datos/DBLAB_XIMENA_FB25.FDB"
|
||||
'dsn' => 'firebird:dbname=localhost:/ruta/DBLAB_XIMENA_FB25.FDB;charset=WIN1252',
|
||||
'user' => 'SYSDBA',
|
||||
'password' => 'masterkey',
|
||||
// Encoding declarado en el FDB (para convertir a UTF-8 durante la extracción)
|
||||
'charset' => 'WIN1252',
|
||||
],
|
||||
|
||||
// ── MySQL (destino) ──────────────────────────────────────────────────
|
||||
'mysql' => [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 3306,
|
||||
'dbname' => 'whatsapp', // nombre de la BD del nuevo sistema
|
||||
'user' => 'root',
|
||||
'password' => '',
|
||||
'charset' => 'utf8mb4',
|
||||
],
|
||||
|
||||
// ── Opciones de migración ────────────────────────────────────────────
|
||||
'options' => [
|
||||
'batch_size' => 500, // registros por INSERT batch
|
||||
'dry_run' => false, // true = solo leer, no insertar
|
||||
'skip_historico' => false, // true = saltar RECEPCION/RELACION/PAGOS
|
||||
'log_file' => __DIR__ . '/etl_' . date('Ymd_His') . '.log',
|
||||
],
|
||||
];
|
||||
@@ -1,90 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Funciones auxiliares compartidas por el ETL
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convierte un string de WIN1252 a UTF-8.
|
||||
* Si el valor ya es UTF-8 válido, lo devuelve sin cambios.
|
||||
*/
|
||||
function toUtf8(?string $val, string $srcEncoding = 'WIN1252'): ?string {
|
||||
if ($val === null) return null;
|
||||
if (mb_check_encoding($val, 'UTF-8')) return $val;
|
||||
return iconv($srcEncoding, 'UTF-8//TRANSLIT//IGNORE', $val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte un array completo de strings (resultado de Firebird) a UTF-8.
|
||||
*/
|
||||
function rowToUtf8(array $row, string $srcEncoding = 'WIN1252'): array {
|
||||
foreach ($row as $k => $v) {
|
||||
if (is_string($v)) {
|
||||
$row[$k] = toUtf8($v, $srcEncoding);
|
||||
}
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia un VARCHAR de Firebird: recorta espacios y convierte encoding.
|
||||
*/
|
||||
function cleanStr(?string $val, string $srcEncoding = 'WIN1252'): ?string {
|
||||
if ($val === null) return null;
|
||||
$val = trim(toUtf8($val, $srcEncoding) ?? '');
|
||||
return $val === '' ? null : $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte fecha Firebird (puede ser DATE o string 'YYYY-MM-DD') a MySQL DATE.
|
||||
* Devuelve null si la fecha es inválida o vacía.
|
||||
*/
|
||||
function fbDate($val): ?string {
|
||||
if ($val === null) return null;
|
||||
if ($val instanceof DateTime) return $val->format('Y-m-d');
|
||||
$str = trim((string)$val);
|
||||
if ($str === '' || $str === '0000-00-00') return null;
|
||||
try {
|
||||
return (new DateTime($str))->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT en batch.
|
||||
* $pdo → conexión MySQL
|
||||
* $table → nombre de la tabla
|
||||
* $rows → array de arrays asociativos con los mismos keys
|
||||
* $ignore → usa INSERT IGNORE para saltar duplicados
|
||||
*/
|
||||
function batchInsert(PDO $pdo, string $table, array $rows, bool $ignore = false): int {
|
||||
if (empty($rows)) return 0;
|
||||
|
||||
$cols = array_keys($rows[0]);
|
||||
$colList = implode(', ', array_map(fn($c) => "`$c`", $cols));
|
||||
$phRow = '(' . implode(', ', array_fill(0, count($cols), '?')) . ')';
|
||||
|
||||
$inserted = 0;
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$placeholders = implode(', ', array_fill(0, count($chunk), $phRow));
|
||||
$keyword = $ignore ? 'INSERT IGNORE' : 'INSERT';
|
||||
$sql = "$keyword INTO `$table` ($colList) VALUES $placeholders";
|
||||
$flat = array_merge(...array_map('array_values', $chunk));
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($flat);
|
||||
$inserted += $stmt->rowCount();
|
||||
}
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
/** Escribe al log y a stdout simultáneamente. */
|
||||
function etlLog(string $msg, $logFp = null): void {
|
||||
$line = '[' . date('H:i:s') . '] ' . $msg . PHP_EOL;
|
||||
echo $line;
|
||||
if ($logFp) fwrite($logFp, $line);
|
||||
}
|
||||
|
||||
/** Devuelve conteo de filas de una tabla Firebird. */
|
||||
function fbCount(PDO $fb, string $table): int {
|
||||
return (int) $fb->query("SELECT COUNT(*) FROM $table")->fetchColumn();
|
||||
}
|
||||
@@ -1,715 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* ETL Firebird 2.5 → MySQL (utf8mb4)
|
||||
* Sistema: DBLAB_XIMENA_FB25 → nuevo sistema WhatsApp-Lab
|
||||
*
|
||||
* Uso:
|
||||
* php run_etl.php [--dry-run] [--skip-historico] [--only=PASO]
|
||||
*
|
||||
* Pasos disponibles (--only):
|
||||
* secciones | especialidades | tipos_muestra | protocolos | items |
|
||||
* perfiles | exam_tipos | tarifas | empresas |
|
||||
* medicos | pacientes | recepciones | relaciones | pagos
|
||||
*
|
||||
* Requerimientos:
|
||||
* - PHP 8.0+ con extensión PDO_Firebird (php-firebird) instalada
|
||||
* - Archivo config.local.php con credenciales reales
|
||||
* - Las migraciones LIS 01-07 ya ejecutadas en MySQL
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '512M');
|
||||
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
|
||||
// ── Configuración ────────────────────────────────────────────────────────────
|
||||
$cfgFile = file_exists(__DIR__ . '/config.local.php')
|
||||
? __DIR__ . '/config.local.php'
|
||||
: __DIR__ . '/config.php';
|
||||
|
||||
$cfg = require $cfgFile;
|
||||
|
||||
// Argumentos CLI
|
||||
$args = array_slice($argv ?? [], 1);
|
||||
$dryRun = in_array('--dry-run', $args, true) || $cfg['options']['dry_run'];
|
||||
$skipHist = in_array('--skip-historico', $args, true) || $cfg['options']['skip_historico'];
|
||||
$onlyPaso = null;
|
||||
foreach ($args as $arg) {
|
||||
if (str_starts_with($arg, '--only=')) {
|
||||
$onlyPaso = strtolower(substr($arg, 7));
|
||||
}
|
||||
}
|
||||
|
||||
$logFp = fopen($cfg['options']['log_file'], 'w');
|
||||
|
||||
etlLog('=== ETL Firebird → MySQL ===', $logFp);
|
||||
etlLog("DRY_RUN: " . ($dryRun ? 'SÍ' : 'NO'), $logFp);
|
||||
if ($onlyPaso) etlLog("Solo paso: $onlyPaso", $logFp);
|
||||
|
||||
// ── Conexiones ───────────────────────────────────────────────────────────────
|
||||
try {
|
||||
$fb = new PDO(
|
||||
$cfg['firebird']['dsn'],
|
||||
$cfg['firebird']['user'],
|
||||
$cfg['firebird']['password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
etlLog('Conexión Firebird OK', $logFp);
|
||||
} catch (\Throwable $e) {
|
||||
etlLog('ERROR conectando Firebird: ' . $e->getMessage(), $logFp);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$my = new PDO(
|
||||
"mysql:host={$cfg['mysql']['host']};port={$cfg['mysql']['port']};dbname={$cfg['mysql']['dbname']};charset=utf8mb4",
|
||||
$cfg['mysql']['user'],
|
||||
$cfg['mysql']['password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => true]
|
||||
);
|
||||
$my->exec("SET NAMES utf8mb4");
|
||||
$my->exec("SET foreign_key_checks = 0");
|
||||
etlLog('Conexión MySQL OK', $logFp);
|
||||
} catch (\Throwable $e) {
|
||||
etlLog('ERROR conectando MySQL: ' . $e->getMessage(), $logFp);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$charset = $cfg['firebird']['charset'];
|
||||
|
||||
// ── Función auxiliar: ¿ejecutar este paso? ──────────────────────────────────
|
||||
function shouldRun(string $paso, ?string $only): bool {
|
||||
return $only === null || $only === $paso;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 1 — SECCION → lab_secciones
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (shouldRun('secciones', $onlyPaso)) {
|
||||
etlLog('--- SECCION → lab_secciones ---', $logFp);
|
||||
$total = fbCount($fb, 'SECCION');
|
||||
etlLog(" Origen: $total registros", $logFp);
|
||||
|
||||
$rows = [];
|
||||
foreach ($fb->query('SELECT CODSECCION, NOMBSECCION FROM SECCION') as $r) {
|
||||
$rows[] = [
|
||||
'codigo' => cleanStr($r['CODSECCION'], $charset),
|
||||
'nombre' => cleanStr($r['NOMBSECCION'], $charset),
|
||||
];
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'lab_secciones', $rows, true);
|
||||
etlLog(" Insertados: $n", $logFp);
|
||||
} else {
|
||||
etlLog(" [DRY] Se insertarían " . count($rows), $logFp);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 2 — ESPECIALIDAD → lab_especialidades
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (shouldRun('especialidades', $onlyPaso)) {
|
||||
etlLog('--- ESPECIALIDAD → lab_especialidades ---', $logFp);
|
||||
$rows = [];
|
||||
foreach ($fb->query('SELECT CODESPECIA, NOMBESPECIA FROM ESPECIALIDAD') as $r) {
|
||||
$rows[] = [
|
||||
'codigo' => cleanStr($r['CODESPECIA'], $charset),
|
||||
'nombre' => cleanStr($r['NOMBESPECIA'], $charset),
|
||||
];
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'lab_especialidades', $rows, true);
|
||||
etlLog(" Insertados: $n / " . count($rows), $logFp);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 3 — EXAMEN.TIPOMUESTRA → lab_tipos_muestra
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (shouldRun('tipos_muestra', $onlyPaso)) {
|
||||
etlLog('--- EXAMEN.TIPOMUESTRA → lab_tipos_muestra ---', $logFp);
|
||||
$rows = [];
|
||||
$seen = [];
|
||||
foreach ($fb->query('SELECT DISTINCT TIPOMUESTRA FROM EXAMEN WHERE TIPOMUESTRA IS NOT NULL') as $r) {
|
||||
$cod = cleanStr($r['TIPOMUESTRA'], $charset);
|
||||
if (!$cod || isset($seen[$cod])) continue;
|
||||
$seen[$cod] = true;
|
||||
$rows[] = [
|
||||
'codigo' => $cod,
|
||||
'nombre' => ucwords(strtolower($cod)), // nombre provisional, editar luego
|
||||
];
|
||||
}
|
||||
etlLog(" Tipos únicos encontrados: " . count($rows), $logFp);
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'lab_tipos_muestra', $rows, true);
|
||||
etlLog(" Insertados: $n", $logFp);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 4 — TARIFAID → lab_tarifas_id
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (shouldRun('tarifas', $onlyPaso)) {
|
||||
etlLog('--- TARIFAID → lab_tarifas_id ---', $logFp);
|
||||
$rows = [];
|
||||
foreach ($fb->query('SELECT IDTARIFA, NOMTARIFA, IDTARIFABASE, PORCENTAJE FROM TARIFAID') as $r) {
|
||||
$rows[] = [
|
||||
'id' => (int)$r['IDTARIFA'],
|
||||
'nombre' => cleanStr($r['NOMTARIFA'], $charset),
|
||||
'tarifa_origen' => $r['IDTARIFABASE'] ? (int)$r['IDTARIFABASE'] : null,
|
||||
'porcentaje' => (float)($r['PORCENTAJE'] ?? 0),
|
||||
];
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'lab_tarifas_id', $rows, true);
|
||||
etlLog(" Insertados: $n / " . count($rows), $logFp);
|
||||
}
|
||||
|
||||
// ── TARIFA → lab_tarifas (104k registros, se hace en batches) ────────
|
||||
etlLog('--- TARIFA → lab_tarifas ---', $logFp);
|
||||
$total = fbCount($fb, 'TARIFA');
|
||||
etlLog(" Origen: $total registros", $logFp);
|
||||
|
||||
$stmt = $fb->query('SELECT CODIGO, IDTARIFA, VALOR, RECARGO_URG, RECARGO_FES, RECARGO_ESP FROM TARIFA');
|
||||
$batch = [];
|
||||
$count = 0;
|
||||
|
||||
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$batch[] = [
|
||||
'cod_examen_legacy' => cleanStr($r['CODIGO'], $charset),
|
||||
'exam_tipo_id' => null, // se resuelve en paso 6 (exam_tipos)
|
||||
'tarifa_id' => (int)$r['IDTARIFA'],
|
||||
'valor' => (float)($r['VALOR'] ?? 0),
|
||||
'recargo_urg' => (float)($r['RECARGO_URG'] ?? 0),
|
||||
'recargo_fes' => (float)($r['RECARGO_FES'] ?? 0),
|
||||
'recargo_esp' => (float)($r['RECARGO_ESP'] ?? 0),
|
||||
];
|
||||
if (count($batch) >= 500) {
|
||||
if (!$dryRun) $count += batchInsert($my, 'lab_tarifas', $batch, true);
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_tarifas', $batch, true);
|
||||
etlLog(" Insertados: $count", $logFp);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 5 — PROTOCOLO + ITEM → lab_protocolos + lab_items_resultado
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (shouldRun('protocolos', $onlyPaso)) {
|
||||
etlLog('--- PROTOCOLO → lab_protocolos ---', $logFp);
|
||||
$rows = [];
|
||||
foreach ($fb->query('SELECT CODPROTOCOLO, NOMPROTOCOLO, CODSECCION, IDPLANILLA, ONLYITEMS FROM PROTOCOLO') as $r) {
|
||||
$rows[] = [
|
||||
'codigo' => cleanStr($r['CODPROTOCOLO'], $charset),
|
||||
'nombre' => cleanStr($r['NOMPROTOCOLO'], $charset),
|
||||
'cod_seccion' => cleanStr($r['CODSECCION'], $charset),
|
||||
'id_planilla' => cleanStr($r['IDPLANILLA'], $charset),
|
||||
'only_show_items' => $r['ONLYITEMS'] ? 1 : 0,
|
||||
];
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'lab_protocolos', $rows, true);
|
||||
etlLog(" Protocolos insertados: $n / " . count($rows), $logFp);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRun('items', $onlyPaso)) {
|
||||
etlLog('--- ITEM → lab_items_resultado ---', $logFp);
|
||||
$total = fbCount($fb, 'ITEM');
|
||||
etlLog(" Origen: $total registros", $logFp);
|
||||
|
||||
$stmt = $fb->query('SELECT CODPROTOCOLO,NOMITEM,TIPOSEXO,TIPO,MEDIDA,ABREVITEM,VMINREF,VMAXREF,ORDEN,FORMULA,CUPS FROM ITEM ORDER BY CODPROTOCOLO, ORDEN');
|
||||
$batch = [];
|
||||
$count = 0;
|
||||
|
||||
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$batch[] = [
|
||||
'cod_protocolo' => cleanStr($r['CODPROTOCOLO'], $charset),
|
||||
'nombre' => cleanStr($r['NOMITEM'], $charset),
|
||||
'tipo_sexo' => cleanStr($r['TIPOSEXO'], $charset),
|
||||
'tipo' => cleanStr($r['TIPO'], $charset),
|
||||
'medida' => cleanStr($r['MEDIDA'], $charset),
|
||||
'abreviatura' => cleanStr($r['ABREVITEM'], $charset),
|
||||
'vmin_ref' => is_numeric($r['VMINREF']) ? (float)$r['VMINREF'] : null,
|
||||
'vmax_ref' => is_numeric($r['VMAXREF']) ? (float)$r['VMAXREF'] : null,
|
||||
'orden' => (int)($r['ORDEN'] ?? 0),
|
||||
'formula' => cleanStr($r['FORMULA'], $charset),
|
||||
'cups_detalle' => cleanStr($r['CUPS'], $charset),
|
||||
];
|
||||
if (count($batch) >= 500) {
|
||||
if (!$dryRun) $count += batchInsert($my, 'lab_items_resultado', $batch, true);
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_items_resultado', $batch, true);
|
||||
etlLog(" Insertados: $count", $logFp);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 6 — PERFIL + PERFIL_EXA → lab_perfiles + lab_perfil_examenes
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (shouldRun('perfiles', $onlyPaso)) {
|
||||
etlLog('--- PERFIL → lab_perfiles ---', $logFp);
|
||||
$rows = [];
|
||||
foreach ($fb->query('SELECT CODPERFIL, NOMPERFIL FROM PERFIL') as $r) {
|
||||
$rows[] = [
|
||||
'nombre' => cleanStr($r['NOMPERFIL'], $charset),
|
||||
];
|
||||
}
|
||||
// Firebird usa VARCHAR código; MySQL usa INT AUTO_INCREMENT
|
||||
// Guardamos el código viejo → id nuevo en memoria para PERFIL_EXA
|
||||
if (!$dryRun) {
|
||||
// Insertar uno a uno para mapear código → id nuevo
|
||||
$mapaPerfiles = [];
|
||||
foreach ($fb->query('SELECT CODPERFIL, NOMPERFIL FROM PERFIL') as $r) {
|
||||
$stmt2 = $my->prepare('INSERT IGNORE INTO lab_perfiles (nombre) VALUES (?)');
|
||||
$stmt2->execute([cleanStr($r['NOMPERFIL'], $charset)]);
|
||||
$newId = (int)$my->lastInsertId();
|
||||
if ($newId) $mapaPerfiles[cleanStr($r['CODPERFIL'], $charset)] = $newId;
|
||||
}
|
||||
etlLog(" Perfiles insertados: " . count($mapaPerfiles), $logFp);
|
||||
|
||||
// PERFIL_EXA — mapear cod_examen → exam_tipo_id
|
||||
etlLog('--- PERFIL_EXA → lab_perfil_examenes ---', $logFp);
|
||||
$examMap = [];
|
||||
foreach ($my->query('SELECT id, codigo_legacy FROM exam_tipos WHERE codigo_legacy IS NOT NULL') as $r) {
|
||||
$examMap[$r['codigo_legacy']] = (int)$r['id'];
|
||||
}
|
||||
|
||||
$peBatch = [];
|
||||
foreach ($fb->query('SELECT CODPERFIL, CODEXAMEN FROM PERFIL_EXA') as $r) {
|
||||
$pId = $mapaPerfiles[cleanStr($r['CODPERFIL'], $charset)] ?? null;
|
||||
$eId = $examMap[cleanStr($r['CODEXAMEN'], $charset)] ?? null;
|
||||
if ($pId && $eId) {
|
||||
$peBatch[] = ['perfil_id' => $pId, 'exam_tipo_id' => $eId];
|
||||
}
|
||||
}
|
||||
$n = batchInsert($my, 'lab_perfil_examenes', $peBatch, true);
|
||||
etlLog(" Relaciones perfil-examen: $n", $logFp);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 7 — EXAMEN → exam_tipos (ampliar con campos legacy)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (shouldRun('exam_tipos', $onlyPaso)) {
|
||||
etlLog('--- EXAMEN → exam_tipos ---', $logFp);
|
||||
$total = fbCount($fb, 'EXAMEN');
|
||||
etlLog(" Origen: $total registros", $logFp);
|
||||
|
||||
$insertados = 0;
|
||||
$actualizados = 0;
|
||||
|
||||
$stmt = $fb->query(
|
||||
'SELECT CODIGO, NOMEXAMEN, CODPROT, TIPOMUESTRA, NIVEL, CUPS,
|
||||
ABREVEXAMEN, SEREMITE, SERECIBE, CODSECCION
|
||||
FROM EXAMEN'
|
||||
);
|
||||
|
||||
$checkStmt = $my->prepare('SELECT id FROM exam_tipos WHERE codigo_legacy = ?');
|
||||
$updStmt = $my->prepare(
|
||||
'UPDATE exam_tipos SET
|
||||
cod_protocolo = ?, tipo_muestra = ?, nivel = ?, cups = ?,
|
||||
abreviatura = ?, seremite = ?, serecibe = ?
|
||||
WHERE codigo_legacy = ?'
|
||||
);
|
||||
$insStmt = $my->prepare(
|
||||
'INSERT IGNORE INTO exam_tipos
|
||||
(codigo, nombre, categoria, codigo_legacy, cod_protocolo,
|
||||
tipo_muestra, nivel, cups, abreviatura, seremite, serecibe)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
|
||||
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$codLeg = cleanStr($r['CODIGO'], $charset);
|
||||
$nombre = cleanStr($r['NOMEXAMEN'], $charset);
|
||||
$proto = cleanStr($r['CODPROT'], $charset);
|
||||
$tMuest = cleanStr($r['TIPOMUESTRA'], $charset);
|
||||
$nivel = $r['NIVEL'] ? (int)$r['NIVEL'] : null;
|
||||
$cups = cleanStr($r['CUPS'], $charset);
|
||||
$abrev = cleanStr($r['ABREVEXAMEN'], $charset);
|
||||
$serem = $r['SEREMITE'] ? 1 : 0;
|
||||
$serec = cleanStr($r['SERECIBE'], $charset);
|
||||
$seccion = cleanStr($r['CODSECCION'], $charset);
|
||||
|
||||
if (!$codLeg || !$nombre) continue;
|
||||
|
||||
$checkStmt->execute([$codLeg]);
|
||||
$existing = $checkStmt->fetchColumn();
|
||||
|
||||
if ($dryRun) continue;
|
||||
|
||||
if ($existing) {
|
||||
$updStmt->execute([$proto, $tMuest, $nivel, $cups, $abrev, $serem, $serec, $codLeg]);
|
||||
$actualizados++;
|
||||
} else {
|
||||
// Usar el código Firebird como código del nuevo sistema (si no hay conflicto)
|
||||
$insStmt->execute([$codLeg, $nombre, $seccion, $codLeg, $proto, $tMuest, $nivel, $cups, $abrev, $serem, $serec]);
|
||||
$insertados++;
|
||||
}
|
||||
}
|
||||
|
||||
etlLog(" Nuevos: $insertados | Actualizados: $actualizados", $logFp);
|
||||
|
||||
// Resolver exam_tipo_id en lab_tarifas (ahora que los exámenes ya están)
|
||||
if (!$dryRun) {
|
||||
etlLog(' Resolviendo exam_tipo_id en lab_tarifas...', $logFp);
|
||||
$updated = $my->exec(
|
||||
'UPDATE lab_tarifas t
|
||||
JOIN exam_tipos e ON e.codigo_legacy = t.cod_examen_legacy
|
||||
SET t.exam_tipo_id = e.id
|
||||
WHERE t.exam_tipo_id IS NULL'
|
||||
);
|
||||
etlLog(" lab_tarifas actualizadas: $updated filas", $logFp);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 8 — EMPRESA + EMPRESA_SUB + EXAMEN_EMP
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (shouldRun('empresas', $onlyPaso)) {
|
||||
etlLog('--- EMPRESA → lab_empresas ---', $logFp);
|
||||
$rows = [];
|
||||
foreach ($fb->query(
|
||||
'SELECT NIT,NOMBRE,RAZSOCIAL,IDTARIFA,DESCUENTO,CODEEPS,
|
||||
TIPOUSUARIO,TIPOUSUARIOSISPRO,CODCONTRATO,CODTERCERO,
|
||||
CENTROCOSTO,REQAUTORIZA,ACTIVA
|
||||
FROM EMPRESA'
|
||||
) as $r) {
|
||||
$rows[] = [
|
||||
'nit' => cleanStr($r['NIT'], $charset),
|
||||
'nombre' => cleanStr($r['NOMBRE'], $charset),
|
||||
'razon_social' => cleanStr($r['RAZSOCIAL'], $charset),
|
||||
'tarifa_id' => $r['IDTARIFA'] ? (int)$r['IDTARIFA'] : null,
|
||||
'descuento_pct' => (float)($r['DESCUENTO'] ?? 0),
|
||||
'codigo_eps' => cleanStr($r['CODEEPS'], $charset),
|
||||
'tipo_usuario' => cleanStr($r['TIPOUSUARIO'], $charset),
|
||||
'tipo_usuario_sispro' => cleanStr($r['TIPOUSUARIOSISPRO'],$charset),
|
||||
'cod_contrato' => cleanStr($r['CODCONTRATO'], $charset),
|
||||
'cod_tercero' => cleanStr($r['CODTERCERO'], $charset),
|
||||
'centro_costo' => cleanStr($r['CENTROCOSTO'], $charset),
|
||||
'req_autoriza' => $r['REQAUTORIZA'] ? 1 : 0,
|
||||
'activa' => $r['ACTIVA'] ? 1 : 0,
|
||||
];
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'lab_empresas', $rows, true);
|
||||
etlLog(" Empresas: $n / " . count($rows), $logFp);
|
||||
}
|
||||
|
||||
etlLog('--- EMPRESA_SUB → lab_empresa_subgrupos ---', $logFp);
|
||||
$rows = [];
|
||||
foreach ($fb->query('SELECT NIT,SUBGRUPO,IDTARIFA,REF_SUBGRUPO,CODCONTRATO FROM EMPRESA_SUB') as $r) {
|
||||
$rows[] = [
|
||||
'nit_empresa' => cleanStr($r['NIT'], $charset),
|
||||
'subgrupo' => cleanStr($r['SUBGRUPO'], $charset),
|
||||
'tarifa_id' => $r['IDTARIFA'] ? (int)$r['IDTARIFA'] : null,
|
||||
'ref_subgrupo' => cleanStr($r['REF_SUBGRUPO'],$charset),
|
||||
'cod_contrato' => cleanStr($r['CODCONTRATO'], $charset),
|
||||
];
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'lab_empresa_subgrupos', $rows, true);
|
||||
etlLog(" Subgrupos: $n", $logFp);
|
||||
}
|
||||
|
||||
etlLog('--- EXAMEN_EMP → lab_examenes_empresa ---', $logFp);
|
||||
$rows = [];
|
||||
foreach ($fb->query('SELECT NIT,CODIGO,CODIGOEMP FROM EXAMEN_EMP') as $r) {
|
||||
$rows[] = [
|
||||
'nit_empresa' => cleanStr($r['NIT'], $charset),
|
||||
'cod_examen_legacy' => cleanStr($r['CODIGO'], $charset),
|
||||
'exam_tipo_id' => null,
|
||||
'codigo_empresa' => cleanStr($r['CODIGOEMP'],$charset),
|
||||
];
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'lab_examenes_empresa', $rows, true);
|
||||
// Resolver exam_tipo_id
|
||||
$my->exec(
|
||||
'UPDATE lab_examenes_empresa ee
|
||||
JOIN exam_tipos e ON e.codigo_legacy = ee.cod_examen_legacy
|
||||
SET ee.exam_tipo_id = e.id
|
||||
WHERE ee.exam_tipo_id IS NULL'
|
||||
);
|
||||
etlLog(" Examenes-empresa: $n", $logFp);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 9 — MEDICO → medicos
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (shouldRun('medicos', $onlyPaso)) {
|
||||
etlLog('--- MEDICO → medicos ---', $logFp);
|
||||
$rows = [];
|
||||
foreach ($fb->query(
|
||||
'SELECT CODMEDICO,NOMBRES,APELLIDOS,CODESPECIA,TELEFONO1,EMAIL,DOCIDMEDICO,ACTIVO
|
||||
FROM MEDICO'
|
||||
) as $r) {
|
||||
$rows[] = [
|
||||
'codigo' => cleanStr($r['CODMEDICO'], $charset),
|
||||
'nombres' => cleanStr($r['NOMBRES'], $charset),
|
||||
'apellidos' => cleanStr($r['APELLIDOS'], $charset),
|
||||
'cod_especialidad'=> cleanStr($r['CODESPECIA'], $charset),
|
||||
'telefonos' => cleanStr($r['TELEFONO1'], $charset),
|
||||
'email' => cleanStr($r['EMAIL'], $charset),
|
||||
'docidmedico' => cleanStr($r['DOCIDMEDICO'], $charset),
|
||||
'activo' => $r['ACTIVO'] ? 1 : 0,
|
||||
];
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'medicos', $rows, true);
|
||||
etlLog(" Médicos: $n / " . count($rows), $logFp);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 10 — PACIENTE → lab_pacientes
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (shouldRun('pacientes', $onlyPaso)) {
|
||||
etlLog('--- PACIENTE → lab_pacientes ---', $logFp);
|
||||
$total = fbCount($fb, 'PACIENTE');
|
||||
etlLog(" Origen: $total registros", $logFp);
|
||||
|
||||
$stmt = $fb->query(
|
||||
'SELECT CODPAC,DOCIDENT,TIPOIDENT,NOMBRES,APELLIDOS,
|
||||
TELEFONO1,EMAIL,FECHANAC,SEXO,CIUDAD,
|
||||
OCUPACION,CODETNIA,TIPORES
|
||||
FROM PACIENTE
|
||||
ORDER BY CODPAC'
|
||||
);
|
||||
|
||||
$checkStmt = $my->prepare('SELECT id FROM lab_pacientes WHERE numero_documento = ?');
|
||||
$insStmt = $my->prepare(
|
||||
'INSERT IGNORE INTO lab_pacientes
|
||||
(numero_documento, tipo_documento, nombre_completo, telefono,
|
||||
email, fecha_nacimiento, genero, ciudad,
|
||||
ocupacion, codetnia, tipores, codigo_legacy, es_historico)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1)'
|
||||
);
|
||||
$updLegacy = $my->prepare(
|
||||
'UPDATE lab_pacientes SET codigo_legacy = ? WHERE numero_documento = ? AND codigo_legacy IS NULL'
|
||||
);
|
||||
|
||||
$nuevos = $coincidentes = 0;
|
||||
|
||||
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$doc = cleanStr($r['DOCIDENT'], $charset);
|
||||
if (!$doc) continue;
|
||||
|
||||
$nombres = cleanStr($r['NOMBRES'], $charset);
|
||||
$apellidos = cleanStr($r['APELLIDOS'], $charset);
|
||||
$nombre = trim("$nombres $apellidos");
|
||||
|
||||
if ($dryRun) continue;
|
||||
|
||||
$checkStmt->execute([$doc]);
|
||||
$existeId = $checkStmt->fetchColumn();
|
||||
|
||||
if ($existeId) {
|
||||
// Ya existe → solo actualizar codigo_legacy si falta
|
||||
$updLegacy->execute([cleanStr($r['CODPAC'], $charset), $doc]);
|
||||
$coincidentes++;
|
||||
} else {
|
||||
$tipoDoc = match(strtoupper(cleanStr($r['TIPOIDENT'], $charset) ?? '')) {
|
||||
'CC' => 'CC',
|
||||
'CE' => 'CE',
|
||||
'TI' => 'TI',
|
||||
'PA' => 'PA',
|
||||
'NIT' => 'NIT',
|
||||
'RC' => 'RC',
|
||||
'MS' => 'MS',
|
||||
default => 'CC',
|
||||
};
|
||||
$genero = match(strtoupper(cleanStr($r['SEXO'], $charset) ?? '')) {
|
||||
'M' => 'M', 'F' => 'F', default => null
|
||||
};
|
||||
$insStmt->execute([
|
||||
$doc,
|
||||
$tipoDoc,
|
||||
$nombre ?: 'Sin nombre',
|
||||
cleanStr($r['TELEFONO1'], $charset),
|
||||
cleanStr($r['EMAIL'], $charset),
|
||||
fbDate($r['FECHANAC']),
|
||||
$genero,
|
||||
cleanStr($r['CIUDAD'], $charset),
|
||||
cleanStr($r['OCUPACION'], $charset),
|
||||
cleanStr($r['CODETNIA'], $charset),
|
||||
cleanStr($r['TIPORES'], $charset),
|
||||
cleanStr($r['CODPAC'], $charset),
|
||||
]);
|
||||
$nuevos++;
|
||||
}
|
||||
}
|
||||
|
||||
etlLog(" Nuevos: $nuevos | Coincidentes (codigo_legacy actualizado): $coincidentes", $logFp);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PASO 11-13 — Histórico transaccional (RECEPCION / RELACION / PAGOS)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
if (!$skipHist) {
|
||||
|
||||
// ── 11. RECEPCION → lab_recepciones ──────────────────────────────────
|
||||
if (shouldRun('recepciones', $onlyPaso)) {
|
||||
etlLog('--- RECEPCION → lab_recepciones ---', $logFp);
|
||||
$total = fbCount($fb, 'RECEPCION');
|
||||
etlLog(" Origen: $total registros", $logFp);
|
||||
|
||||
// Mapa paciente legacy → id nuevo
|
||||
$pacMap = [];
|
||||
foreach ($my->query('SELECT id, codigo_legacy FROM lab_pacientes WHERE codigo_legacy IS NOT NULL') as $r) {
|
||||
$pacMap[$r['codigo_legacy']] = (int)$r['id'];
|
||||
}
|
||||
// Mapa médico legacy → id nuevo
|
||||
$medMap = [];
|
||||
foreach ($my->query('SELECT id, codigo FROM medicos WHERE codigo IS NOT NULL') as $r) {
|
||||
$medMap[$r['codigo']] = (int)$r['id'];
|
||||
}
|
||||
|
||||
$stmt = $fb->query(
|
||||
'SELECT IDRECEPCION,CODPAC,CODMEDICO,NIT,SUBGRUPO,
|
||||
FECHA,HORAINICIO,PREFIJO,NUMFACTURA,
|
||||
VALORTOTAL,VALORDESC,DIAGPPAL,TIPOUSUARIO,AUTORIZACION,USUARIO
|
||||
FROM RECEPCION
|
||||
ORDER BY IDRECEPCION'
|
||||
);
|
||||
$batch = [];
|
||||
$count = 0;
|
||||
|
||||
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$codPac = cleanStr($r['CODPAC'], $charset);
|
||||
$codMed = cleanStr($r['CODMEDICO'],$charset);
|
||||
|
||||
$batch[] = [
|
||||
'id' => (int)$r['IDRECEPCION'],
|
||||
'cod_paciente_legacy' => $codPac,
|
||||
'paciente_id' => $pacMap[$codPac] ?? null,
|
||||
'cod_medico_legacy' => $codMed,
|
||||
'medico_id' => $medMap[$codMed] ?? null,
|
||||
'nit_empresa' => cleanStr($r['NIT'], $charset),
|
||||
'subgrupo' => cleanStr($r['SUBGRUPO'], $charset),
|
||||
'fecha_recepcion' => fbDate($r['FECHA']),
|
||||
'hora_inicio' => cleanStr($r['HORAINICIO'], $charset),
|
||||
'prefijo' => cleanStr($r['PREFIJO'], $charset),
|
||||
'num_factura' => $r['NUMFACTURA'] ? (int)$r['NUMFACTURA'] : null,
|
||||
'valor_total' => (float)($r['VALORTOTAL'] ?? 0),
|
||||
'valor_desc' => (float)($r['VALORDESC'] ?? 0),
|
||||
'diag_ppal' => cleanStr($r['DIAGPPAL'], $charset),
|
||||
'tipo_usuario' => cleanStr($r['TIPOUSUARIO'], $charset),
|
||||
'autorizacion' => cleanStr($r['AUTORIZACION'], $charset),
|
||||
'usuario' => cleanStr($r['USUARIO'], $charset),
|
||||
];
|
||||
|
||||
if (count($batch) >= 500) {
|
||||
if (!$dryRun) $count += batchInsert($my, 'lab_recepciones', $batch, true);
|
||||
$batch = [];
|
||||
if ($count % 5000 === 0) etlLog(" ... $count procesadas", $logFp);
|
||||
}
|
||||
}
|
||||
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_recepciones', $batch, true);
|
||||
etlLog(" Insertadas: $count", $logFp);
|
||||
}
|
||||
|
||||
// ── 12. RELACION → lab_relaciones ─────────────────────────────────────
|
||||
if (shouldRun('relaciones', $onlyPaso)) {
|
||||
etlLog('--- RELACION → lab_relaciones ---', $logFp);
|
||||
$total = fbCount($fb, 'RELACION');
|
||||
etlLog(" Origen: $total registros", $logFp);
|
||||
|
||||
// Mapa cod_examen_legacy → exam_tipo_id
|
||||
$examMap = [];
|
||||
foreach ($my->query('SELECT id, codigo_legacy FROM exam_tipos WHERE codigo_legacy IS NOT NULL') as $r) {
|
||||
$examMap[$r['codigo_legacy']] = (int)$r['id'];
|
||||
}
|
||||
|
||||
$stmt = $fb->query(
|
||||
'SELECT IDRECEPCION,CODIGO,PRECIO,FECHAREPORT,REPORTADO,REPORPOR,VALIDADO,USRVALIDA,FECHAVALIDA
|
||||
FROM RELACION'
|
||||
);
|
||||
$batch = [];
|
||||
$count = 0;
|
||||
|
||||
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$codExam = cleanStr($r['CODIGO'], $charset);
|
||||
$batch[] = [
|
||||
'recepcion_id' => (int)$r['IDRECEPCION'],
|
||||
'cod_examen_legacy' => $codExam,
|
||||
'exam_tipo_id' => $examMap[$codExam] ?? null,
|
||||
'precio' => (float)($r['PRECIO'] ?? 0),
|
||||
'fecha_reportado' => fbDate($r['FECHAREPORT']),
|
||||
'reportado' => $r['REPORTADO'] ? 1 : 0,
|
||||
'reportado_por' => cleanStr($r['REPORPOR'], $charset),
|
||||
'validado' => $r['VALIDADO'] ? 1 : 0,
|
||||
'usuario_valida' => cleanStr($r['USRVALIDA'], $charset),
|
||||
'fecha_valida' => fbDate($r['FECHAVALIDA']),
|
||||
];
|
||||
|
||||
if (count($batch) >= 500) {
|
||||
if (!$dryRun) $count += batchInsert($my, 'lab_relaciones', $batch, true);
|
||||
$batch = [];
|
||||
if ($count % 10000 === 0) etlLog(" ... $count", $logFp);
|
||||
}
|
||||
}
|
||||
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_relaciones', $batch, true);
|
||||
etlLog(" Insertadas: $count", $logFp);
|
||||
}
|
||||
|
||||
// ── 13. PAGOS + PAGOS_DET ─────────────────────────────────────────────
|
||||
if (shouldRun('pagos', $onlyPaso)) {
|
||||
etlLog('--- PAGOS → lab_pagos ---', $logFp);
|
||||
$rows = [];
|
||||
foreach ($fb->query('SELECT NUMCAJA,IDRECEPCION,VALOR,FECHAPAGO,USUARIO FROM PAGOS') as $r) {
|
||||
$rows[] = [
|
||||
'numcaja_legacy' => (int)$r['NUMCAJA'],
|
||||
'recepcion_id' => (int)$r['IDRECEPCION'],
|
||||
'valor' => (float)($r['VALOR'] ?? 0),
|
||||
'fecha' => fbDate($r['FECHAPAGO']),
|
||||
'usuario' => cleanStr($r['USUARIO'], $charset),
|
||||
];
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'lab_pagos', $rows, true);
|
||||
etlLog(" Pagos: $n / " . count($rows), $logFp);
|
||||
}
|
||||
|
||||
etlLog('--- PAGOS_DET → lab_pagos_det ---', $logFp);
|
||||
// Mapa numcaja → id nuevo
|
||||
$pagoMap = [];
|
||||
if (!$dryRun) {
|
||||
foreach ($my->query('SELECT id, numcaja_legacy FROM lab_pagos') as $r) {
|
||||
$pagoMap[$r['numcaja_legacy']] = (int)$r['id'];
|
||||
}
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($fb->query('SELECT NUMCAJA,TIPOPAGO,VALOR,NUMDOC FROM PAGOS_DET') as $r) {
|
||||
$pagoId = $pagoMap[(int)$r['NUMCAJA']] ?? null;
|
||||
if (!$pagoId) continue;
|
||||
$rows[] = [
|
||||
'pago_id' => $pagoId,
|
||||
'tipo_pago' => cleanStr($r['TIPOPAGO'], $charset),
|
||||
'valor' => (float)($r['VALOR'] ?? 0),
|
||||
'num_doc' => cleanStr($r['NUMDOC'], $charset),
|
||||
];
|
||||
}
|
||||
if (!$dryRun) {
|
||||
$n = batchInsert($my, 'lab_pagos_det', $rows, true);
|
||||
etlLog(" Detalles de pago: $n", $logFp);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
etlLog('--- Histórico omitido (--skip-historico) ---', $logFp);
|
||||
}
|
||||
|
||||
// ── Restaurar FK checks ──────────────────────────────────────────────────────
|
||||
if (!$dryRun) {
|
||||
$my->exec("SET foreign_key_checks = 1");
|
||||
}
|
||||
|
||||
etlLog('=== ETL COMPLETADO ===', $logFp);
|
||||
fclose($logFp);
|
||||
echo "Log guardado en: {$cfg['options']['log_file']}\n";
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Migración: registra lab_tomas_config en system_modules
|
||||
* Ejecutar: php migrations/run_20260710_lab_tomas_config_module.php
|
||||
*/
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
|
||||
// Buscar el sort_order máximo en la categoría 'clinico' para poner al final
|
||||
$row = $pdo->query("SELECT MAX(sort_order) AS mx FROM system_modules WHERE category = 'clinico'")->fetch(PDO::FETCH_ASSOC);
|
||||
$nextOrder = (int)($row['mx'] ?? 50) + 10;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO system_modules (slug, name, icon, category, route, is_active, sort_order, description)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
icon = VALUES(icon),
|
||||
category = VALUES(category),
|
||||
route = VALUES(route),
|
||||
is_active = 1,
|
||||
sort_order = VALUES(sort_order),
|
||||
description = VALUES(description)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
'lab_tomas_config',
|
||||
'Tipos de Examen (Tomas)',
|
||||
'fas fa-vials',
|
||||
'clinico',
|
||||
'/lab_tomas_config.php',
|
||||
$nextOrder,
|
||||
'Configurar tipos de examen y ciclos de tomas prolongadas (F-LAB-28)',
|
||||
]);
|
||||
|
||||
echo "✅ Módulo lab_tomas_config registrado en system_modules (sort_order=$nextOrder).\n";
|
||||
@@ -12,9 +12,6 @@ $pendientes = [
|
||||
'20260703_medicos.sql',
|
||||
'20260703_medicos_seed.sql',
|
||||
'20260703_solicitud_medico.sql',
|
||||
'20260707_turnero_consent_origen_lugar.sql',
|
||||
'20260716_lab_eps_ciudades.sql',
|
||||
'20260716_numero_recibo_tarjeta.sql',
|
||||
];
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php return [
|
||||
'slug' => 'lab_ciudades',
|
||||
'name' => 'Ciudades',
|
||||
'icon' => 'fas fa-map-marker-alt',
|
||||
'category' => 'lab',
|
||||
'route' => '/lab_ciudades.php',
|
||||
'is_active' => true,
|
||||
'sort_order' => 23,
|
||||
'oleada' => 0,
|
||||
'description' => 'Gestión de ciudades de pacientes',
|
||||
'links' => [['name' => 'Ciudades', 'icon' => 'fas fa-map-marker-alt', 'route' => '/lab_ciudades.php']],
|
||||
];
|
||||
@@ -1,149 +0,0 @@
|
||||
<?php
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
requireRole('admin');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Ciudades — <?= htmlspecialchars($_cfg['empresa_nombre'] ?? 'ERP') ?></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link href="<?= BASE_URL ?>assets/css/styles.css?v=15" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.ciu-table { width:100%; border-collapse:collapse; font-size:.9rem; }
|
||||
.ciu-table th { background:#f8fafc; font-weight:700; font-size:.75rem; text-transform:uppercase;
|
||||
letter-spacing:.06em; color:#64748b; padding:.6rem 1rem; border-bottom:2px solid #e2e8f0; }
|
||||
.ciu-table td { padding:.65rem 1rem; border-bottom:1px solid #f1f5f9; vertical-align:middle; }
|
||||
.ciu-table tr:hover td { background:#f8fafc; }
|
||||
.badge-activa { background:#dcfce7; color:#166534; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:99px; }
|
||||
.badge-inactiva { background:#f1f5f9; color:#94a3b8; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:99px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<?php include APP_ROOT . '/partials/navbar.php'; ?>
|
||||
|
||||
<div class="container-fluid py-4" style="max-width:720px">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h5 class="mb-0"><i class="fas fa-map-marker-alt me-2 text-primary"></i>Ciudades</h5>
|
||||
<button class="btn btn-primary btn-sm" onclick="abrirModal()">
|
||||
<i class="fas fa-plus me-1"></i>Nueva ciudad
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="ciu-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th style="width:90px">Estado</th>
|
||||
<th style="width:110px">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-ciu">
|
||||
<tr><td colspan="3" class="text-muted text-center py-3">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="modal-ciu" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title" id="modal-ciu-titulo">Nueva ciudad</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="ciu-id">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Nombre <span class="text-danger">*</span></label>
|
||||
<input type="text" id="ciu-nombre" class="form-control" maxlength="100" placeholder="Ej: Cúcuta, Bogotá…">
|
||||
</div>
|
||||
<div id="ciu-error" class="text-danger small d-none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="guardar()">
|
||||
<i class="fas fa-save me-1"></i>Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '<?= BASE_URL ?>api/lab/ciudades.php';
|
||||
let _modal;
|
||||
|
||||
async function cargar() {
|
||||
const r = await fetch(API + '?action=list');
|
||||
const j = await r.json();
|
||||
const tbody = document.getElementById('tbody-ciu');
|
||||
if (!j.ok || !j.ciudades.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="text-muted text-center py-3">Sin registros</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = j.ciudades.map(c => `
|
||||
<tr>
|
||||
<td>${escHtml(c.nombre)}</td>
|
||||
<td><span class="badge-${c.activa == 1 ? 'activa' : 'inactiva'}">${c.activa == 1 ? 'Activa' : 'Inactiva'}</span></td>
|
||||
<td class="d-flex gap-1">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="abrirModal(${c.id},'${escHtml(c.nombre).replace(/'/g,"\\'")}')"><i class="fas fa-pen"></i></button>
|
||||
<button class="btn btn-outline-${c.activa == 1 ? 'warning' : 'success'} btn-sm" onclick="toggle(${c.id})"><i class="fas fa-${c.activa == 1 ? 'ban' : 'check'}"></i></button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="eliminar(${c.id},'${escHtml(c.nombre).replace(/'/g,"\\'")}')"><i class="fas fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function abrirModal(id = 0, nombre = '') {
|
||||
document.getElementById('ciu-id').value = id;
|
||||
document.getElementById('ciu-nombre').value = nombre;
|
||||
document.getElementById('ciu-error').classList.add('d-none');
|
||||
document.getElementById('modal-ciu-titulo').textContent = id ? 'Editar ciudad' : 'Nueva ciudad';
|
||||
_modal = _modal || new bootstrap.Modal(document.getElementById('modal-ciu'));
|
||||
_modal.show();
|
||||
setTimeout(() => document.getElementById('ciu-nombre').focus(), 300);
|
||||
}
|
||||
|
||||
async function guardar() {
|
||||
const nombre = document.getElementById('ciu-nombre').value.trim();
|
||||
const id = parseInt(document.getElementById('ciu-id').value) || 0;
|
||||
const errEl = document.getElementById('ciu-error');
|
||||
errEl.classList.add('d-none');
|
||||
if (!nombre) { errEl.textContent = 'El nombre es requerido.'; errEl.classList.remove('d-none'); return; }
|
||||
const r = await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'save', id, nombre}) });
|
||||
const j = await r.json();
|
||||
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||
_modal.hide();
|
||||
cargar();
|
||||
}
|
||||
|
||||
async function toggle(id) {
|
||||
await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'toggle', id}) });
|
||||
cargar();
|
||||
}
|
||||
|
||||
async function eliminar(id, nombre) {
|
||||
if (!confirm(`¿Eliminar "${nombre}"?`)) return;
|
||||
const r = await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'delete', id}) });
|
||||
const j = await r.json();
|
||||
if (!j.ok) { alert(j.error); return; }
|
||||
cargar();
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(String(str)));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
document.getElementById('ciu-nombre').addEventListener('keydown', e => { if (e.key === 'Enter') guardar(); });
|
||||
cargar();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
return [
|
||||
'slug' => 'lab_empresas',
|
||||
'name' => 'Empresas y Convenios',
|
||||
'icon' => 'fas fa-building',
|
||||
'category' => 'clinico',
|
||||
'route' => '/erp.php?m=lab_empresas&v=index',
|
||||
'is_active' => true,
|
||||
'sort_order' => 65,
|
||||
'oleada' => 2,
|
||||
'description' => 'Gestión de empresas, EPS, convenios, subgrupos y tarifas.',
|
||||
'links' => [
|
||||
['name' => 'Empresas', 'icon' => 'fas fa-building', 'route' => '/erp.php?m=lab_empresas&v=index'],
|
||||
['name' => 'Tarifas', 'icon' => 'fas fa-tags', 'route' => '/erp.php?m=lab_empresas&v=tarifas'],
|
||||
],
|
||||
];
|
||||
@@ -1,556 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/lab_empresas/views/index.php
|
||||
* CRUD de empresas/convenios y sus subgrupos.
|
||||
*/
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
Layout::open('Empresas y Convenios', 'fas fa-building');
|
||||
?>
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<!-- Encabezado -->
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="fas fa-building me-2 text-primary"></i>Empresas y Convenios</h4>
|
||||
<small class="text-muted">EPS, IPS, convenios y particulares con tarifa especial</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?= BASE_URL ?>/erp.php?m=lab_empresas&v=tarifas"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-tags me-1"></i>Tarifas
|
||||
</a>
|
||||
<button class="btn btn-primary btn-sm" onclick="abrirModal()">
|
||||
<i class="fas fa-plus me-1"></i>Nueva empresa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-sm-5 col-md-4">
|
||||
<input type="search" id="filtroSearch" class="form-control form-control-sm"
|
||||
placeholder="Buscar por nombre, NIT…" oninput="debounceCargar()">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select id="filtroActiva" class="form-select form-select-sm" onchange="cargarEmpresas()">
|
||||
<option value="">Todas</option>
|
||||
<option value="1" selected>Activas</option>
|
||||
<option value="0">Inactivas</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto ms-auto">
|
||||
<span class="text-muted small" id="lblTotal"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0" id="tablaEmpresas">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>NIT</th>
|
||||
<th>Nombre</th>
|
||||
<th>Tarifa</th>
|
||||
<th class="text-end">Dcto%</th>
|
||||
<th class="text-center">Subgrupos</th>
|
||||
<th class="text-center">Autoriza</th>
|
||||
<th class="text-center">Estado</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbodyEmpresas">
|
||||
<tr><td colspan="8" class="text-center py-4 text-muted">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Paginación -->
|
||||
<div class="card-footer d-flex justify-content-between align-items-center py-1">
|
||||
<button class="btn btn-outline-secondary btn-sm" id="btnPrev" onclick="cambiarPagina(-1)">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<span class="small text-muted" id="lblPagina"></span>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="btnNext" onclick="cambiarPagina(1)">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Modal empresa (crear / editar)
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div class="modal fade" id="modalEmpresa" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modalEmpresaTitulo">Nueva empresa</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="formEmpresa" novalidate>
|
||||
<input type="hidden" id="fNitOrig" value="">
|
||||
|
||||
<!-- Fila 1: NIT + Nombre -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label fw-semibold small">NIT <span class="text-danger">*</span></label>
|
||||
<input type="text" id="fNit" class="form-control form-control-sm"
|
||||
placeholder="900123456-7" maxlength="20" required>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<label class="form-label fw-semibold small">Nombre <span class="text-danger">*</span></label>
|
||||
<input type="text" id="fNombre" class="form-control form-control-sm"
|
||||
placeholder="Nombre de la empresa" maxlength="200" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fila 2: Razón social -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Razón social</label>
|
||||
<input type="text" id="fRazonSocial" class="form-control form-control-sm"
|
||||
placeholder="Razón social completa" maxlength="200">
|
||||
</div>
|
||||
|
||||
<!-- Fila 3: Tarifa + Descuento -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label fw-semibold small">Tarifa</label>
|
||||
<select id="fTarifaId" class="form-select form-select-sm">
|
||||
<option value="">— Sin tarifa especial —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label fw-semibold small">Descuento %</label>
|
||||
<input type="number" id="fDescuentoPct" class="form-control form-control-sm"
|
||||
value="0" min="0" max="100" step="0.01">
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label fw-semibold small">Cód. EPS</label>
|
||||
<input type="text" id="fCodigoEps" class="form-control form-control-sm"
|
||||
placeholder="EPS001" maxlength="20">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fila 4: Tipo usuario -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label fw-semibold small">Tipo usuario</label>
|
||||
<select id="fTipoUsuario" class="form-select form-select-sm">
|
||||
<option value="">—</option>
|
||||
<option value="01">01 - Contributivo</option>
|
||||
<option value="02">02 - Subsidiado</option>
|
||||
<option value="03">03 - Vinculado</option>
|
||||
<option value="04">04 - Particular</option>
|
||||
<option value="05">05 - ARP</option>
|
||||
<option value="06">06 - Póliza</option>
|
||||
<option value="07">07 - Estudiante</option>
|
||||
<option value="08">08 - Empleado</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label fw-semibold small">Tipo usuario SISPRO</label>
|
||||
<input type="text" id="fTipoUsuarioSispro" class="form-control form-control-sm"
|
||||
placeholder="Código SISPRO" maxlength="10">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label fw-semibold small">Cod. tercero</label>
|
||||
<input type="text" id="fCodTercero" class="form-control form-control-sm"
|
||||
placeholder="Cód. tercero" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fila 5: Contrato / Centro costo -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label fw-semibold small">Cód. contrato</label>
|
||||
<input type="text" id="fCodContrato" class="form-control form-control-sm"
|
||||
placeholder="Número de contrato" maxlength="50">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label fw-semibold small">Centro de costo</label>
|
||||
<input type="text" id="fCentroCosto" class="form-control form-control-sm"
|
||||
placeholder="Centro de costo" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fila 6: Checks -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-switch mt-1">
|
||||
<input class="form-check-input" type="checkbox" id="fReqAutoriza">
|
||||
<label class="form-check-label small" for="fReqAutoriza">
|
||||
Exige número de autorización
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-switch mt-1">
|
||||
<input class="form-check-input" type="checkbox" id="fActiva" checked>
|
||||
<label class="form-check-label small" for="fActiva">Activa</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje de error -->
|
||||
<div id="formError" class="alert alert-danger d-none py-2 small"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="btnGuardarEmpresa" onclick="guardarEmpresa()">
|
||||
<i class="fas fa-save me-1"></i>Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Modal subgrupos
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div class="modal fade" id="modalSubgrupos" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-layer-group me-2 text-secondary"></i>
|
||||
Subgrupos — <span id="subNitNombre"></span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="listaSubgrupos" class="mb-3"></div>
|
||||
<hr>
|
||||
<h6 class="small fw-semibold text-muted text-uppercase mb-2">Agregar / editar subgrupo</h6>
|
||||
<input type="hidden" id="subId" value="">
|
||||
<input type="hidden" id="subNitEmpresa" value="">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-12">
|
||||
<input type="text" id="subNombre" class="form-control form-control-sm"
|
||||
placeholder="Nombre del subgrupo" maxlength="100">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<select id="subTarifaId" class="form-select form-select-sm">
|
||||
<option value="">— Tarifa de la empresa —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<input type="text" id="subRefSubgrupo" class="form-control form-control-sm"
|
||||
placeholder="Ref. subgrupo" maxlength="50">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="text" id="subCodContrato" class="form-control form-control-sm"
|
||||
placeholder="Cód. contrato subgrupo" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
<div id="subError" class="alert alert-danger d-none py-2 small mb-2"></div>
|
||||
<button class="btn btn-sm btn-primary w-100" onclick="guardarSubgrupo()">
|
||||
<i class="fas fa-save me-1"></i>Guardar subgrupo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.badge-tarifa { font-size:.72rem; background:#e0f2fe; color:#0369a1; border-radius:4px; padding:2px 7px; }
|
||||
.badge-activa { font-size:.72rem; }
|
||||
.sub-row { background:#f8fafc; border-radius:6px; padding:8px 12px; margin-bottom:6px;
|
||||
border:1px solid #e2e8f0; display:flex; align-items:center; gap:8px; }
|
||||
.sub-row .sub-info { flex:1 }
|
||||
.sub-row .sub-info .sub-nombre { font-weight:600; font-size:.9rem; }
|
||||
.sub-row .sub-info .sub-meta { font-size:.78rem; color:#64748b; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const BASE = '<?= BASE_URL ?>';
|
||||
let _pagina = 1;
|
||||
let _pages = 1;
|
||||
let _tarifas = [];
|
||||
let _modalEmpresa, _modalSubgrupos;
|
||||
|
||||
// ─── Init ─────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
_modalEmpresa = new bootstrap.Modal(document.getElementById('modalEmpresa'));
|
||||
_modalSubgrupos = new bootstrap.Modal(document.getElementById('modalSubgrupos'));
|
||||
cargarTarifas().then(() => cargarEmpresas());
|
||||
});
|
||||
|
||||
// ─── Tarifas ──────────────────────────────────────────────────
|
||||
async function cargarTarifas() {
|
||||
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`);
|
||||
const j = await res.json();
|
||||
_tarifas = j.tarifas || [];
|
||||
const opts = _tarifas.map(t =>
|
||||
`<option value="${t.id}">${escHtml(t.nombre)}${t.porcentaje > 0 ? ` (+${t.porcentaje}%)` : ''}</option>`
|
||||
).join('');
|
||||
document.getElementById('fTarifaId').innerHTML = '<option value="">— Sin tarifa especial —</option>' + opts;
|
||||
document.getElementById('subTarifaId').innerHTML = '<option value="">— Tarifa de la empresa —</option>' + opts;
|
||||
}
|
||||
|
||||
// ─── Lista empresas ───────────────────────────────────────────
|
||||
let _debTimer;
|
||||
function debounceCargar() {
|
||||
clearTimeout(_debTimer);
|
||||
_debTimer = setTimeout(() => { _pagina = 1; cargarEmpresas(); }, 350);
|
||||
}
|
||||
|
||||
async function cargarEmpresas() {
|
||||
const search = document.getElementById('filtroSearch').value.trim();
|
||||
const activa = document.getElementById('filtroActiva').value;
|
||||
const params = new URLSearchParams({ action:'list', page:_pagina, limit:30 });
|
||||
if (search) params.append('search', search);
|
||||
if (activa !== '') params.append('activa', activa);
|
||||
|
||||
const tbody = document.getElementById('tbodyEmpresas');
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-3 text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando…</td></tr>';
|
||||
|
||||
const res = await fetch(`${BASE}/api/lab/empresas.php?${params}`);
|
||||
const j = await res.json();
|
||||
|
||||
_pages = j.pages || 1;
|
||||
document.getElementById('lblTotal').textContent = `${j.total} empresa(s)`;
|
||||
document.getElementById('lblPagina').textContent = `Página ${_pagina} de ${_pages}`;
|
||||
document.getElementById('btnPrev').disabled = _pagina <= 1;
|
||||
document.getElementById('btnNext').disabled = _pagina >= _pages;
|
||||
|
||||
if (!j.empresas || !j.empresas.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-muted">Sin resultados</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = j.empresas.map(e => `
|
||||
<tr>
|
||||
<td class="text-monospace small fw-semibold">${escHtml(e.nit)}</td>
|
||||
<td>
|
||||
<div class="fw-semibold">${escHtml(e.nombre)}</div>
|
||||
${e.razon_social ? `<div class="text-muted small">${escHtml(e.razon_social)}</div>` : ''}
|
||||
</td>
|
||||
<td>${e.tarifa_nombre ? `<span class="badge-tarifa">${escHtml(e.tarifa_nombre)}</span>` : '<span class="text-muted small">—</span>'}</td>
|
||||
<td class="text-end small">${parseFloat(e.descuento_pct) > 0 ? escHtml(e.descuento_pct)+'%' : '—'}</td>
|
||||
<td class="text-center">
|
||||
${parseInt(e.total_subgrupos) > 0
|
||||
? `<button class="btn btn-link btn-sm p-0 text-primary" onclick="abrirSubgrupos('${escAttr(e.nit)}','${escAttr(e.nombre)}')">${e.total_subgrupos} <i class="fas fa-layer-group ms-1"></i></button>`
|
||||
: `<button class="btn btn-link btn-sm p-0 text-muted" onclick="abrirSubgrupos('${escAttr(e.nit)}','${escAttr(e.nombre)}')">+ subgrupo</button>`
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">${parseInt(e.req_autoriza) ? '<i class="fas fa-check-circle text-warning" title="Exige autorización"></i>' : '—'}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge ${parseInt(e.activa) ? 'bg-success' : 'bg-secondary'} badge-activa">
|
||||
${parseInt(e.activa) ? 'Activa' : 'Inactiva'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end pe-2">
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2 me-1" onclick="abrirModal('${escAttr(e.nit)}')" title="Editar">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-${parseInt(e.activa) ? 'warning' : 'success'} btn-sm py-0 px-2" onclick="toggleActiva('${escAttr(e.nit)}')" title="${parseInt(e.activa) ? 'Desactivar' : 'Activar'}">
|
||||
<i class="fas fa-${parseInt(e.activa) ? 'ban' : 'check'}"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function cambiarPagina(delta) {
|
||||
_pagina = Math.max(1, Math.min(_pages, _pagina + delta));
|
||||
cargarEmpresas();
|
||||
}
|
||||
|
||||
// ─── Modal empresa ────────────────────────────────────────────
|
||||
async function abrirModal(nit = null) {
|
||||
resetFormError();
|
||||
document.getElementById('fNitOrig').value = nit || '';
|
||||
document.getElementById('modalEmpresaTitulo').textContent = nit ? 'Editar empresa' : 'Nueva empresa';
|
||||
document.getElementById('fNit').readOnly = !!nit;
|
||||
|
||||
// Reset
|
||||
['fNit','fNombre','fRazonSocial','fCodigoEps','fTipoUsuarioSispro',
|
||||
'fCodTercero','fCodContrato','fCentroCosto'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('fTarifaId').value = '';
|
||||
document.getElementById('fTipoUsuario').value = '';
|
||||
document.getElementById('fDescuentoPct').value= '0';
|
||||
document.getElementById('fReqAutoriza').checked = false;
|
||||
document.getElementById('fActiva').checked = true;
|
||||
|
||||
if (nit) {
|
||||
const res = await fetch(`${BASE}/api/lab/empresas.php?action=get&nit=${encodeURIComponent(nit)}`);
|
||||
const j = await res.json();
|
||||
const e = j.empresa;
|
||||
document.getElementById('fNit').value = e.nit;
|
||||
document.getElementById('fNombre').value = e.nombre;
|
||||
document.getElementById('fRazonSocial').value = e.razon_social || '';
|
||||
document.getElementById('fTarifaId').value = e.tarifa_id || '';
|
||||
document.getElementById('fDescuentoPct').value = e.descuento_pct || 0;
|
||||
document.getElementById('fCodigoEps').value = e.codigo_eps || '';
|
||||
document.getElementById('fTipoUsuario').value = e.tipo_usuario || '';
|
||||
document.getElementById('fTipoUsuarioSispro').value = e.tipo_usuario_sispro || '';
|
||||
document.getElementById('fCodTercero').value = e.cod_tercero || '';
|
||||
document.getElementById('fCodContrato').value = e.cod_contrato || '';
|
||||
document.getElementById('fCentroCosto').value = e.centro_costo || '';
|
||||
document.getElementById('fReqAutoriza').checked = !!parseInt(e.req_autoriza);
|
||||
document.getElementById('fActiva').checked = !!parseInt(e.activa);
|
||||
}
|
||||
_modalEmpresa.show();
|
||||
}
|
||||
|
||||
async function guardarEmpresa() {
|
||||
resetFormError();
|
||||
const nit = document.getElementById('fNit').value.trim();
|
||||
if (!nit) return showFormError('El NIT es obligatorio');
|
||||
if (!document.getElementById('fNombre').value.trim()) return showFormError('El nombre es obligatorio');
|
||||
|
||||
const btn = document.getElementById('btnGuardarEmpresa');
|
||||
btn.disabled = true;
|
||||
|
||||
const payload = {
|
||||
action: 'save',
|
||||
nit,
|
||||
nombre: document.getElementById('fNombre').value.trim(),
|
||||
razon_social: document.getElementById('fRazonSocial').value.trim(),
|
||||
tarifa_id: document.getElementById('fTarifaId').value,
|
||||
descuento_pct: document.getElementById('fDescuentoPct').value,
|
||||
codigo_eps: document.getElementById('fCodigoEps').value.trim(),
|
||||
tipo_usuario: document.getElementById('fTipoUsuario').value,
|
||||
tipo_usuario_sispro: document.getElementById('fTipoUsuarioSispro').value.trim(),
|
||||
cod_tercero: document.getElementById('fCodTercero').value.trim(),
|
||||
cod_contrato: document.getElementById('fCodContrato').value.trim(),
|
||||
centro_costo: document.getElementById('fCentroCosto').value.trim(),
|
||||
req_autoriza: document.getElementById('fReqAutoriza').checked,
|
||||
activa: document.getElementById('fActiva').checked,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/lab/empresas.php`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!j.ok) return showFormError(j.error || 'Error al guardar');
|
||||
_modalEmpresa.hide();
|
||||
cargarEmpresas();
|
||||
} catch(e) {
|
||||
showFormError('Error de red: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActiva(nit) {
|
||||
await fetch(`${BASE}/api/lab/empresas.php`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action:'toggle', nit})
|
||||
});
|
||||
cargarEmpresas();
|
||||
}
|
||||
|
||||
function showFormError(msg) { const el = document.getElementById('formError'); el.textContent = msg; el.classList.remove('d-none'); }
|
||||
function resetFormError() { document.getElementById('formError').classList.add('d-none'); }
|
||||
|
||||
// ─── Modal subgrupos ─────────────────────────────────────────
|
||||
async function abrirSubgrupos(nit, nombre) {
|
||||
document.getElementById('subNitNombre').textContent = nombre;
|
||||
document.getElementById('subNitEmpresa').value = nit;
|
||||
document.getElementById('subId').value = '';
|
||||
['subNombre','subRefSubgrupo','subCodContrato'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('subTarifaId').value = '';
|
||||
document.getElementById('subError').classList.add('d-none');
|
||||
await recargarSubgrupos(nit);
|
||||
_modalSubgrupos.show();
|
||||
}
|
||||
|
||||
async function recargarSubgrupos(nit) {
|
||||
if (!nit) nit = document.getElementById('subNitEmpresa').value;
|
||||
const res = await fetch(`${BASE}/api/lab/empresa_subgrupos.php?nit_empresa=${encodeURIComponent(nit)}`);
|
||||
const j = await res.json();
|
||||
const lista = document.getElementById('listaSubgrupos');
|
||||
if (!j.subgrupos || !j.subgrupos.length) {
|
||||
lista.innerHTML = '<p class="text-muted small text-center">Sin subgrupos</p>';
|
||||
return;
|
||||
}
|
||||
lista.innerHTML = j.subgrupos.map(s => `
|
||||
<div class="sub-row">
|
||||
<div class="sub-info">
|
||||
<div class="sub-nombre">${escHtml(s.subgrupo)}</div>
|
||||
<div class="sub-meta">
|
||||
${s.tarifa_nombre ? `<span class="badge-tarifa me-2">${escHtml(s.tarifa_nombre)}</span>` : ''}
|
||||
${s.ref_subgrupo ? `Ref: ${escHtml(s.ref_subgrupo)}` : ''}
|
||||
${s.cod_contrato ? ` · Cto: ${escHtml(s.cod_contrato)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2" onclick="editarSubgrupo(${s.id},'${escAttr(s.subgrupo)}','${escAttr(s.tarifa_id||'')}','${escAttr(s.ref_subgrupo||'')}','${escAttr(s.cod_contrato||'')}')" title="Editar">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-2" onclick="eliminarSubgrupo(${s.id})" title="Eliminar">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function editarSubgrupo(id, nombre, tarifaId, ref, codCon) {
|
||||
document.getElementById('subId').value = id;
|
||||
document.getElementById('subNombre').value = nombre;
|
||||
document.getElementById('subTarifaId').value = tarifaId;
|
||||
document.getElementById('subRefSubgrupo').value = ref;
|
||||
document.getElementById('subCodContrato').value = codCon;
|
||||
document.getElementById('subNombre').focus();
|
||||
}
|
||||
|
||||
async function guardarSubgrupo() {
|
||||
const errEl = document.getElementById('subError');
|
||||
errEl.classList.add('d-none');
|
||||
const nit = document.getElementById('subNitEmpresa').value;
|
||||
const nombre = document.getElementById('subNombre').value.trim();
|
||||
if (!nombre) { errEl.textContent='El nombre es obligatorio'; errEl.classList.remove('d-none'); return; }
|
||||
|
||||
const payload = {
|
||||
action: 'save',
|
||||
id: document.getElementById('subId').value || null,
|
||||
nit_empresa: nit,
|
||||
subgrupo: nombre,
|
||||
tarifa_id: document.getElementById('subTarifaId').value,
|
||||
ref_subgrupo: document.getElementById('subRefSubgrupo').value.trim(),
|
||||
cod_contrato: document.getElementById('subCodContrato').value.trim(),
|
||||
};
|
||||
const res = await fetch(`${BASE}/api/lab/empresa_subgrupos.php`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||
// Reset form
|
||||
document.getElementById('subId').value = '';
|
||||
['subNombre','subRefSubgrupo','subCodContrato'].forEach(id => document.getElementById(id).value='');
|
||||
document.getElementById('subTarifaId').value = '';
|
||||
recargarSubgrupos(nit);
|
||||
cargarEmpresas();
|
||||
}
|
||||
|
||||
async function eliminarSubgrupo(id) {
|
||||
if (!confirm('¿Eliminar este subgrupo?')) return;
|
||||
const nit = document.getElementById('subNitEmpresa').value;
|
||||
await fetch(`${BASE}/api/lab/empresa_subgrupos.php`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action:'delete', id})
|
||||
});
|
||||
recargarSubgrupos(nit);
|
||||
cargarEmpresas();
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
function escHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function escAttr(s) { return escHtml(s).replace(/'/g,'''); }
|
||||
</script>
|
||||
<?php Layout::close(); ?>
|
||||
@@ -1,212 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/lab_empresas/views/tarifas.php
|
||||
* CRUD del catálogo de tarifas (lab_tarifas_id).
|
||||
*/
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
Layout::open('Catálogo de Tarifas', 'fas fa-tags');
|
||||
?>
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="fas fa-tags me-2 text-primary"></i>Catálogo de Tarifas</h4>
|
||||
<small class="text-muted">IDs de tarifa usados en el motor de precios</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?= BASE_URL ?>/erp.php?m=lab_empresas&v=index"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-building me-1"></i>Empresas
|
||||
</a>
|
||||
<button class="btn btn-primary btn-sm" onclick="abrirForm()">
|
||||
<i class="fas fa-plus me-1"></i>Nueva tarifa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Tabla tarifas -->
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover table-sm mb-0" id="tablaTarifas">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:60px">ID</th>
|
||||
<th>Nombre</th>
|
||||
<th class="text-end">%</th>
|
||||
<th>Origen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbodyTarifas">
|
||||
<tr><td colspan="5" class="text-center py-4 text-muted">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-2">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Tarifas con <strong>% > 0</strong> y <strong>Origen</strong> se calculan automáticamente
|
||||
como: precio_origen × (1 + %/100).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Formulario -->
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="mb-0 small fw-semibold" id="formTitulo">Nueva tarifa</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">ID numérico</label>
|
||||
<input type="number" id="tId" class="form-control form-control-sm"
|
||||
placeholder="Dejar vacío para auto-asignar" min="1">
|
||||
<div class="form-text">El ID debe coincidir con el ID Firebird si se va a migrar.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Nombre <span class="text-danger">*</span></label>
|
||||
<input type="text" id="tNombre" class="form-control form-control-sm"
|
||||
placeholder="Ej: PARTICULAR, EPS SURA" maxlength="150">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Porcentaje sobre tarifa origen</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" id="tPorcentaje" class="form-control"
|
||||
value="0" min="0" step="0.01" max="999">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
<div class="form-text">0 = precios fijos. >0 = derivada de otra tarifa.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Tarifa origen</label>
|
||||
<select id="tOrigen" class="form-select form-select-sm">
|
||||
<option value="">— Precios directos —</option>
|
||||
</select>
|
||||
<div class="form-text">Solo si esta tarifa deriva de otra por porcentaje.</div>
|
||||
</div>
|
||||
<div id="tError" class="alert alert-danger d-none py-2 small mb-2"></div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm flex-fill" onclick="guardarTarifa()">
|
||||
<i class="fas fa-save me-1"></i>Guardar
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="resetForm()">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const BASE = '<?= BASE_URL ?>';
|
||||
let _tarifas = [];
|
||||
let _editId = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => cargarTarifas());
|
||||
|
||||
async function cargarTarifas() {
|
||||
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`);
|
||||
const j = await res.json();
|
||||
_tarifas = j.tarifas || [];
|
||||
|
||||
// Poblar select origen (excluyendo la propia tarifa en edición)
|
||||
const optsOrigen = _tarifas
|
||||
.filter(t => _editId === null || t.id != _editId)
|
||||
.map(t => `<option value="${t.id}">${escHtml(t.nombre)} (ID ${t.id})</option>`)
|
||||
.join('');
|
||||
document.getElementById('tOrigen').innerHTML = '<option value="">— Precios directos —</option>' + optsOrigen;
|
||||
|
||||
const tbody = document.getElementById('tbodyTarifas');
|
||||
if (!_tarifas.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-4 text-muted">Sin tarifas registradas</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = _tarifas.map(t => `
|
||||
<tr>
|
||||
<td class="text-monospace fw-semibold text-primary">${t.id}</td>
|
||||
<td>${escHtml(t.nombre)}</td>
|
||||
<td class="text-end">${parseFloat(t.porcentaje) > 0 ? `<span class="badge bg-info text-dark">+${t.porcentaje}%</span>` : '—'}</td>
|
||||
<td>${t.tarifa_origen_nombre ? `<span class="small text-muted">${escHtml(t.tarifa_origen_nombre)}</span>` : '—'}</td>
|
||||
<td class="text-end pe-2">
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2 me-1"
|
||||
onclick="editarTarifa(${t.id})" title="Editar">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-2"
|
||||
onclick="eliminarTarifa(${t.id})" title="Eliminar">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function editarTarifa(id) {
|
||||
const t = _tarifas.find(x => x.id == id);
|
||||
if (!t) return;
|
||||
_editId = id;
|
||||
document.getElementById('formTitulo').textContent = 'Editar tarifa';
|
||||
document.getElementById('tId').value = t.id;
|
||||
document.getElementById('tId').readOnly = true;
|
||||
document.getElementById('tNombre').value = t.nombre;
|
||||
document.getElementById('tPorcentaje').value = t.porcentaje;
|
||||
document.getElementById('tOrigen').value = t.tarifa_origen || '';
|
||||
document.getElementById('tError').classList.add('d-none');
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
_editId = null;
|
||||
document.getElementById('formTitulo').textContent = 'Nueva tarifa';
|
||||
document.getElementById('tId').value = '';
|
||||
document.getElementById('tId').readOnly = false;
|
||||
document.getElementById('tNombre').value = '';
|
||||
document.getElementById('tPorcentaje').value = '0';
|
||||
document.getElementById('tOrigen').value = '';
|
||||
document.getElementById('tError').classList.add('d-none');
|
||||
}
|
||||
|
||||
function abrirForm() { resetForm(); document.getElementById('tNombre').focus(); }
|
||||
|
||||
async function guardarTarifa() {
|
||||
const errEl = document.getElementById('tError');
|
||||
errEl.classList.add('d-none');
|
||||
const nombre = document.getElementById('tNombre').value.trim();
|
||||
if (!nombre) { errEl.textContent='El nombre es obligatorio'; errEl.classList.remove('d-none'); return; }
|
||||
|
||||
const payload = {
|
||||
action: 'save',
|
||||
nombre,
|
||||
porcentaje: document.getElementById('tPorcentaje').value,
|
||||
tarifa_origen:document.getElementById('tOrigen').value,
|
||||
};
|
||||
const idVal = document.getElementById('tId').value.trim();
|
||||
if (idVal) payload.id = parseInt(idVal);
|
||||
|
||||
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||
resetForm();
|
||||
cargarTarifas();
|
||||
}
|
||||
|
||||
async function eliminarTarifa(id) {
|
||||
if (!confirm('¿Eliminar esta tarifa? Solo es posible si no tiene precios de exámenes asociados.')) return;
|
||||
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action:'delete', id})
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!j.ok) { alert(j.error || 'Error al eliminar'); return; }
|
||||
cargarTarifas();
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<?php Layout::close(); ?>
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php return [
|
||||
'slug' => 'lab_eps',
|
||||
'name' => 'EPS',
|
||||
'icon' => 'fas fa-hospital',
|
||||
'category' => 'lab',
|
||||
'route' => '/lab_eps.php',
|
||||
'is_active' => true,
|
||||
'sort_order' => 22,
|
||||
'oleada' => 0,
|
||||
'description' => 'Gestión de EPS y aseguradoras',
|
||||
'links' => [['name' => 'EPS', 'icon' => 'fas fa-hospital', 'route' => '/lab_eps.php']],
|
||||
];
|
||||
@@ -1,178 +0,0 @@
|
||||
<?php
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
requireRole('admin');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>EPS — <?= htmlspecialchars($_cfg['empresa_nombre'] ?? 'ERP') ?></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link href="<?= BASE_URL ?>assets/css/styles.css?v=15" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.eps-table { width:100%; border-collapse:collapse; font-size:.9rem; }
|
||||
.eps-table th { background:#f8fafc; font-weight:700; font-size:.75rem; text-transform:uppercase;
|
||||
letter-spacing:.06em; color:#64748b; padding:.6rem 1rem; border-bottom:2px solid #e2e8f0; }
|
||||
.eps-table td { padding:.65rem 1rem; border-bottom:1px solid #f1f5f9; vertical-align:middle; }
|
||||
.eps-table tr:hover td { background:#f8fafc; }
|
||||
.badge-activa { background:#dcfce7; color:#166534; font-size:.7rem; font-weight:700;
|
||||
padding:2px 8px; border-radius:99px; }
|
||||
.badge-inactiva { background:#f1f5f9; color:#94a3b8; font-size:.7rem; font-weight:700;
|
||||
padding:2px 8px; border-radius:99px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<?php include APP_ROOT . '/partials/navbar.php'; ?>
|
||||
|
||||
<div class="container-fluid py-4" style="max-width:720px">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h5 class="mb-0"><i class="fas fa-hospital me-2 text-primary"></i>EPS / Aseguradoras</h5>
|
||||
<button class="btn btn-primary btn-sm" onclick="abrirModal()">
|
||||
<i class="fas fa-plus me-1"></i>Nueva EPS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="eps-table" id="tbl-eps">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th style="width:90px">Estado</th>
|
||||
<th style="width:110px">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-eps">
|
||||
<tr><td colspan="3" class="text-muted text-center py-3">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="modal-eps" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title" id="modal-eps-titulo">Nueva EPS</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="eps-id">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Nombre <span class="text-danger">*</span></label>
|
||||
<input type="text" id="eps-nombre" class="form-control" maxlength="120"
|
||||
placeholder="Ej: Nueva EPS, Sanitas, Sura…">
|
||||
</div>
|
||||
<div id="eps-error" class="text-danger small d-none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="guardarEps()">
|
||||
<i class="fas fa-save me-1"></i>Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_EPS = '<?= BASE_URL ?>api/lab/eps.php';
|
||||
let _modal;
|
||||
|
||||
async function cargarEps() {
|
||||
const r = await fetch(API_EPS + '?action=list');
|
||||
const j = await r.json();
|
||||
const tbody = document.getElementById('tbody-eps');
|
||||
if (!j.ok || !j.eps.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="text-muted text-center py-3">Sin registros</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = j.eps.map(e => `
|
||||
<tr id="row-${e.id}">
|
||||
<td>${escHtml(e.nombre)}</td>
|
||||
<td>
|
||||
<span class="badge-${e.activa == 1 ? 'activa' : 'inactiva'}">
|
||||
${e.activa == 1 ? 'Activa' : 'Inactiva'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="d-flex gap-1">
|
||||
<button class="btn btn-outline-secondary btn-sm" title="Editar" onclick="abrirModal(${e.id},'${escHtml(e.nombre).replace(/'/g,"\\'")}')">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-${e.activa == 1 ? 'warning' : 'success'} btn-sm" title="${e.activa == 1 ? 'Desactivar' : 'Activar'}" onclick="toggleEps(${e.id})">
|
||||
<i class="fas fa-${e.activa == 1 ? 'ban' : 'check'}"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" title="Eliminar" onclick="eliminarEps(${e.id},'${escHtml(e.nombre).replace(/'/g,"\\'")}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function abrirModal(id = 0, nombre = '') {
|
||||
document.getElementById('eps-id').value = id;
|
||||
document.getElementById('eps-nombre').value = nombre;
|
||||
document.getElementById('eps-error').classList.add('d-none');
|
||||
document.getElementById('modal-eps-titulo').textContent = id ? 'Editar EPS' : 'Nueva EPS';
|
||||
_modal = _modal || new bootstrap.Modal(document.getElementById('modal-eps'));
|
||||
_modal.show();
|
||||
setTimeout(() => document.getElementById('eps-nombre').focus(), 300);
|
||||
}
|
||||
|
||||
async function guardarEps() {
|
||||
const nombre = document.getElementById('eps-nombre').value.trim();
|
||||
const id = parseInt(document.getElementById('eps-id').value) || 0;
|
||||
const errEl = document.getElementById('eps-error');
|
||||
errEl.classList.add('d-none');
|
||||
if (!nombre) { errEl.textContent = 'El nombre es requerido.'; errEl.classList.remove('d-none'); return; }
|
||||
|
||||
const r = await fetch(API_EPS, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'save', id, nombre }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||
_modal.hide();
|
||||
cargarEps();
|
||||
}
|
||||
|
||||
async function toggleEps(id) {
|
||||
await fetch(API_EPS, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'toggle', id }),
|
||||
});
|
||||
cargarEps();
|
||||
}
|
||||
|
||||
async function eliminarEps(id, nombre) {
|
||||
if (!confirm(`¿Eliminar "${nombre}"?`)) return;
|
||||
const r = await fetch(API_EPS, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete', id }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) { alert(j.error); return; }
|
||||
cargarEps();
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
const d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(String(str)));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
document.getElementById('eps-nombre').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') guardarEps();
|
||||
});
|
||||
|
||||
cargarEps();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
error_reporting(E_ERROR | E_PARSE);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
|
||||
register_shutdown_function(function () {
|
||||
$err = error_get_last();
|
||||
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
ob_clean();
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => 'Error interno: ' . $err['message']]);
|
||||
}
|
||||
});
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
|
||||
|
||||
function jsonOk(array $data = [], string $msg = ''): never {
|
||||
ob_clean();
|
||||
$r = ['ok' => true];
|
||||
if ($msg) $r['message'] = $msg;
|
||||
if ($data) $r = array_merge($r, $data);
|
||||
echo json_encode($r);
|
||||
exit;
|
||||
}
|
||||
|
||||
function jsonError(string $msg, int $code = 400): never {
|
||||
ob_clean();
|
||||
http_response_code($code);
|
||||
echo json_encode(['ok' => false, 'error' => $msg]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function requireAdmin(): void {
|
||||
if (!isUserLoggedIn()) jsonError('No autorizado', 401);
|
||||
$role = $_SESSION['admin_user']['role'] ?? '';
|
||||
if (!in_array($role, ['superadmin', 'admin', 'supervisor'], true)) jsonError('Sin permiso', 403);
|
||||
}
|
||||
|
||||
function requireLogin(): void {
|
||||
if (!isUserLoggedIn()) jsonError('No autorizado', 401);
|
||||
}
|
||||
|
||||
function inputJson(): array {
|
||||
return json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
}
|
||||
|
||||
function db(): PDO {
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$pdo->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
return $pdo;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?id=N — Detalle de un examen del catálogo con sus ítems.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$id) jsonError('ID requerido');
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$s = $pdo->prepare('SELECT * FROM exam_tipos WHERE id = ?');
|
||||
$s->execute([$id]);
|
||||
$exam = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$exam) jsonError('Examen no encontrado', 404);
|
||||
|
||||
$items = $pdo->prepare(
|
||||
'SELECT * FROM lab_items_resultado WHERE cod_protocolo = ? ORDER BY orden, id'
|
||||
);
|
||||
$items->execute([$exam['cod_protocolo'] ?? $exam['codigo']]);
|
||||
|
||||
jsonOk(['data' => $exam, 'items' => $items->fetchAll(PDO::FETCH_ASSOC)]);
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?exam_id=N — Tarifas de un examen por empresa o convenio.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
$examId = (int)($_GET['exam_id'] ?? 0);
|
||||
if (!$examId) jsonError('exam_id requerido');
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$s = $pdo->prepare('SELECT 1 FROM exam_tipos WHERE id = ?');
|
||||
$s->execute([$examId]);
|
||||
if (!$s->fetchColumn()) jsonError('Examen no encontrado', 404);
|
||||
|
||||
$s = $pdo->prepare(
|
||||
"SELECT ti.id AS tarifa_id, ti.nombre AS tarifa_nombre,
|
||||
lt.id AS precio_id, lt.valor, lt.recargo_urg, lt.recargo_fes, lt.recargo_esp
|
||||
FROM lab_tarifas_id ti
|
||||
LEFT JOIN lab_tarifas lt ON lt.tarifa_id = ti.id AND lt.exam_tipo_id = ?
|
||||
ORDER BY ti.id"
|
||||
);
|
||||
$s->execute([$examId]);
|
||||
|
||||
jsonOk(['data' => $s->fetchAll(PDO::FETCH_ASSOC)]);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user