up
This commit is contained in:
+129
@@ -0,0 +1,129 @@
|
||||
# Módulo Administrativo — Laboratorio Clínico
|
||||
|
||||
Módulo add-on para el sistema de chatbot WhatsApp que permite gestionar órdenes médicas recibidas como imágenes, domicilios, enfermeras y pacientes.
|
||||
|
||||
---
|
||||
|
||||
## Instalación
|
||||
|
||||
### 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)
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/**
|
||||
* API - Exportar conversaciones a CSV
|
||||
*/
|
||||
|
||||
require_once '../config/config.php';
|
||||
|
||||
// Verificar autenticación
|
||||
requireAuthentication();
|
||||
|
||||
// Parámetros de filtro opcionales
|
||||
$phone = trim($_GET['phone'] ?? '');
|
||||
$direction = trim($_GET['direction'] ?? ''); // incoming | outgoing | ''
|
||||
$dateFrom = trim($_GET['date_from'] ?? '');
|
||||
$dateTo = trim($_GET['date_to'] ?? '');
|
||||
|
||||
// Construir WHERE
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($phone !== '') {
|
||||
$where[] = 'u.phone_number LIKE :phone';
|
||||
$params[':phone'] = '%' . $phone . '%';
|
||||
}
|
||||
if (in_array($direction, ['incoming', 'outgoing'], true)) {
|
||||
$where[] = 'c.direction = :direction';
|
||||
$params[':direction'] = $direction;
|
||||
}
|
||||
if ($dateFrom !== '') {
|
||||
$where[] = 'c.created_at >= :date_from';
|
||||
$params[':date_from'] = $dateFrom . ' 00:00:00';
|
||||
}
|
||||
if ($dateTo !== '') {
|
||||
$where[] = 'c.created_at <= :date_to';
|
||||
$params[':date_to'] = $dateTo . ' 23:59:59';
|
||||
}
|
||||
|
||||
$whereSQL = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
|
||||
|
||||
// Nombre de archivo dinámico
|
||||
$filename = 'conversaciones_' . date('Y-m-d');
|
||||
if ($dateFrom || $dateTo) $filename .= '_' . ($dateFrom ?: 'inicio') . '_a_' . ($dateTo ?: 'hoy');
|
||||
$filename .= '.csv';
|
||||
|
||||
// Headers para descarga
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
c.id AS id,
|
||||
u.phone_number AS telefono,
|
||||
COALESCE(u.name, 'Sin nombre') AS nombre_usuario,
|
||||
c.direction AS direccion,
|
||||
c.message_type AS tipo_mensaje,
|
||||
c.status AS estado,
|
||||
CASE WHEN c.is_read = 1 THEN 'Sí' ELSE 'No' END AS leido,
|
||||
REPLACE(REPLACE(COALESCE(c.content,''), '\r\n', ' '), '\n', ' ')
|
||||
AS contenido,
|
||||
c.media_url AS url_media,
|
||||
c.filename AS archivo,
|
||||
c.created_at AS fecha_hora
|
||||
FROM conversations c
|
||||
INNER JOIN users u ON u.id = c.user_id
|
||||
$whereSQL
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT 50000
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// BOM UTF-8 para compatibilidad con Excel
|
||||
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
|
||||
// Encabezados CSV
|
||||
fputcsv($output, [
|
||||
'ID',
|
||||
'Teléfono',
|
||||
'Nombre Usuario',
|
||||
'Dirección',
|
||||
'Tipo Mensaje',
|
||||
'Estado',
|
||||
'Leído',
|
||||
'Contenido',
|
||||
'URL Media',
|
||||
'Archivo',
|
||||
'Fecha y Hora',
|
||||
], ';');
|
||||
|
||||
// Filas
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
fputcsv($output, [
|
||||
$row['id'],
|
||||
$row['telefono'],
|
||||
$row['nombre_usuario'],
|
||||
$row['direccion'],
|
||||
$row['tipo_mensaje'],
|
||||
$row['estado'],
|
||||
$row['leido'],
|
||||
$row['contenido'],
|
||||
$row['url_media'] ?? '',
|
||||
$row['archivo'] ?? '',
|
||||
$row['fecha_hora'] ? date('d/m/Y H:i:s', strtotime($row['fecha_hora'])) : '',
|
||||
], ';');
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('export_conversations.php error: ' . $e->getMessage());
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Content-Disposition: inline');
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Error al exportar conversaciones']);
|
||||
}
|
||||
@@ -32,6 +32,8 @@ try {
|
||||
c.created_at as last_message_time,
|
||||
c.status as last_message_status,
|
||||
u.advisor_requested as advisor_requested,
|
||||
u.terms_pending as terms_pending,
|
||||
u.terms_accepted_at as terms_accepted_at,
|
||||
COUNT(*) as total_conversations,
|
||||
SUM(CASE WHEN c.direction = 'incoming' AND c.status = 'received' THEN 1 ELSE 0 END) as unread_count
|
||||
FROM users u
|
||||
@@ -41,7 +43,7 @@ try {
|
||||
FROM conversations
|
||||
GROUP BY user_id
|
||||
)
|
||||
GROUP BY u.id, u.phone_number, u.name, c.content, c.direction, c.message_type, c.created_at, c.status, u.advisor_requested
|
||||
GROUP BY u.id, u.phone_number, u.name, c.content, c.direction, c.message_type, c.created_at, c.status, u.advisor_requested, u.terms_pending, u.terms_accepted_at
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT 50"
|
||||
);
|
||||
@@ -63,6 +65,8 @@ try {
|
||||
'last_message_time' => $conv['last_message_time'],
|
||||
'last_message_status' => $conv['last_message_status'] ?? 'sent',
|
||||
'advisor_requested' => !empty($conv['advisor_requested']) ? true : false,
|
||||
'terms_pending' => !empty($conv['terms_pending']) ? true : false,
|
||||
'terms_accepted_at' => $conv['terms_accepted_at'] ?? null,
|
||||
'total_conversations' => intval($conv['total_conversations']),
|
||||
'unread_count' => intval($conv['unread_count']),
|
||||
'time_ago' => timeAgo($conv['last_message_time'])
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* GET api/get_terms_acceptances.php
|
||||
* Lista paginada de aceptaciones de T&C con stats y filtros.
|
||||
*
|
||||
* Query params:
|
||||
* estado = aceptado | rechazado | pendiente (opcional)
|
||||
* fecha = YYYY-MM-DD (filtra por fecha de envío, día completo)
|
||||
* phone = string parcial o completo del número
|
||||
* page = int (default 1)
|
||||
* per_page = int (default 50, máx 200)
|
||||
*/
|
||||
|
||||
session_start();
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
requireAuthentication();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
// ── Parámetros ────────────────────────────────────────────────────────
|
||||
$estado = in_array($_GET['estado'] ?? '', ['aceptado', 'rechazado', 'pendiente'])
|
||||
? $_GET['estado'] : null;
|
||||
$fecha = !empty($_GET['fecha']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['fecha'])
|
||||
? $_GET['fecha'] : null;
|
||||
$phone = isset($_GET['phone']) ? trim($_GET['phone']) : '';
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$perPage = min(200, max(1, (int)($_GET['per_page'] ?? 50)));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// ── WHERE ─────────────────────────────────────────────────────────────
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($estado !== null) {
|
||||
$where[] = "ta.estado COLLATE utf8mb4_unicode_ci = :estado";
|
||||
$params[':estado'] = $estado;
|
||||
}
|
||||
if ($fecha !== null) {
|
||||
$where[] = 'DATE(ta.fecha_envio) = :fecha';
|
||||
$params[':fecha'] = $fecha;
|
||||
}
|
||||
if ($phone !== '') {
|
||||
$where[] = "ta.phone_number COLLATE utf8mb4_unicode_ci LIKE :phone";
|
||||
$params[':phone'] = '%' . $phone . '%';
|
||||
}
|
||||
|
||||
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
// ── Stats (sin filtro de paginación) ──────────────────────────────────
|
||||
$statsSQL = "
|
||||
SELECT
|
||||
COALESCE(SUM(ta.estado COLLATE utf8mb4_unicode_ci = 'aceptado'), 0) AS aceptado,
|
||||
COALESCE(SUM(ta.estado COLLATE utf8mb4_unicode_ci = 'rechazado'), 0) AS rechazado,
|
||||
COALESCE(SUM(ta.estado COLLATE utf8mb4_unicode_ci = 'pendiente'), 0) AS pendiente,
|
||||
COUNT(*) AS total
|
||||
FROM terms_acceptance ta
|
||||
$whereClause
|
||||
";
|
||||
$stmtStats = $pdo->prepare($statsSQL);
|
||||
$stmtStats->execute($params);
|
||||
$statsRow = $stmtStats->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$stats = [
|
||||
'aceptado' => (int)($statsRow['aceptado'] ?? 0),
|
||||
'rechazado' => (int)($statsRow['rechazado'] ?? 0),
|
||||
'pendiente' => (int)($statsRow['pendiente'] ?? 0),
|
||||
];
|
||||
$total = (int)($statsRow['total'] ?? 0);
|
||||
|
||||
// ── Datos paginados ───────────────────────────────────────────────────
|
||||
$dataSQL = "
|
||||
SELECT
|
||||
ta.id,
|
||||
ta.phone_number,
|
||||
u.name AS user_name,
|
||||
ta.estado,
|
||||
tv.version AS terms_version,
|
||||
ta.fecha_envio,
|
||||
ta.fecha_respuesta
|
||||
FROM terms_acceptance ta
|
||||
LEFT JOIN users u ON u.phone_number COLLATE utf8mb4_unicode_ci = ta.phone_number COLLATE utf8mb4_unicode_ci
|
||||
LEFT JOIN terms_versions tv ON tv.id = ta.terms_version_id
|
||||
$whereClause
|
||||
ORDER BY ta.fecha_envio DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
";
|
||||
|
||||
$stmtData = $pdo->prepare($dataSQL);
|
||||
foreach ($params as $k => $v) {
|
||||
$stmtData->bindValue($k, $v);
|
||||
}
|
||||
$stmtData->bindValue(':limit', $perPage, PDO::PARAM_INT);
|
||||
$stmtData->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmtData->execute();
|
||||
$rows = $stmtData->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => $rows,
|
||||
'stats' => $stats,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pages' => (int)ceil($total / $perPage),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* API - Obtener configuración de Términos y Condiciones
|
||||
*/
|
||||
|
||||
require_once '../config/config.php';
|
||||
|
||||
error_reporting(E_ALL);
|
||||
@ini_set('display_errors', 0);
|
||||
|
||||
requireAuthentication();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$active = $db->fetch(
|
||||
"SELECT id, version, documento_url, documento_nombre, mensaje_aceptacion,
|
||||
mensaje_rechazo, forzar_reenvio, activa, created_at
|
||||
FROM terms_versions
|
||||
WHERE activa = 1
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
|
||||
echo json_encode(['success' => true, 'data' => $active ?: null]);
|
||||
} catch (Exception $e) {
|
||||
error_log('get_terms_config.php error: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* Helpers compartidos para los endpoints del módulo de laboratorio.
|
||||
* Incluido automáticamente por cada endpoint de /api/lab/.
|
||||
*/
|
||||
|
||||
// Capturar cualquier salida accidental (warnings, notices) antes del JSON
|
||||
ob_start();
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../classes/lab/ActividadAdmin.php';
|
||||
require_once __DIR__ . '/../../classes/lab/Paciente.php';
|
||||
require_once __DIR__ . '/../../classes/lab/Enfermera.php';
|
||||
require_once __DIR__ . '/../../classes/lab/OrdenMedica.php';
|
||||
require_once __DIR__ . '/../../classes/lab/Domicilio.php';
|
||||
require_once __DIR__ . '/../../classes/lab/Asignacion.php';
|
||||
require_once __DIR__ . '/../../classes/lab/Formulario.php';
|
||||
|
||||
// Los endpoints API nunca deben mostrar errores PHP en HTML — enviar como JSON
|
||||
error_reporting(E_ERROR | E_PARSE);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('html_errors', '0');
|
||||
|
||||
// Interceptar errores fatales y devolverlos como JSON en lugar de HTML
|
||||
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 del servidor: ' . $err['message'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Cabeceras JSON estándar
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
requireAuthentication();
|
||||
|
||||
/**
|
||||
* ID del admin autenticado actualmente.
|
||||
*/
|
||||
function adminId(): ?int {
|
||||
return isset($_SESSION['admin_user']['id'])
|
||||
? (int)$_SESSION['admin_user']['id']
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rol del usuario autenticado ('admin' | 'enfermero').
|
||||
*/
|
||||
function userRole(): string {
|
||||
return $_SESSION['admin_user']['role'] ?? 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detiene la ejecución con 403 JSON si el usuario no es admin (bloquea enfermeros).
|
||||
*/
|
||||
function requireAdmin(): void {
|
||||
if (userRole() !== 'admin') {
|
||||
jsonError('Acceso restringido a administradores', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detiene la ejecución si no se puede actuar como enfermero.
|
||||
* Acepta tanto admins (que pueden actuar en nombre de cualquier enfermera)
|
||||
* como el propio enfermero autenticado.
|
||||
*/
|
||||
function requireEnfermeroAccess(int $enfermeraIdSolicitado): void {
|
||||
if (userRole() === 'admin') return;
|
||||
$propio = enfermeraId();
|
||||
if (!$propio || $propio !== $enfermeraIdSolicitado) {
|
||||
jsonError('Solo puedes gestionar tus propios domicilios', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee el body JSON del request.
|
||||
*/
|
||||
function inputJson(): array {
|
||||
static $parsed = null;
|
||||
if ($parsed === null) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$parsed = json_decode($raw, true) ?? [];
|
||||
}
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve una respuesta de éxito.
|
||||
*/
|
||||
function jsonOk(array $payload = [], string $mensaje = ''): void {
|
||||
ob_clean();
|
||||
$resp = ['ok' => true, 'success' => true];
|
||||
if ($mensaje) {
|
||||
$resp['message'] = $mensaje;
|
||||
}
|
||||
echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve un error JSON con código HTTP.
|
||||
*/
|
||||
function jsonError(string $mensaje, int $code = 400): void {
|
||||
ob_clean();
|
||||
http_response_code($code);
|
||||
echo json_encode(['ok' => false, 'success' => false, 'error' => $mensaje], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida que el método HTTP sea el esperado.
|
||||
*/
|
||||
function requireMethod(string $method): void {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) {
|
||||
jsonError('Método no permitido', 405);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/autorizar_orden.php
|
||||
* Cambia el estado de una orden médica (flujo de revisión).
|
||||
* Body JSON:
|
||||
* { id, accion: 'en_revision'|'autorizada'|'rechazada'|'completada', comentario? }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
$om = new OrdenMedica();
|
||||
$admin = adminId();
|
||||
|
||||
if (empty($datos['id'])) {
|
||||
jsonError('El campo id es obligatorio');
|
||||
}
|
||||
if (empty($datos['accion'])) {
|
||||
jsonError('El campo accion es obligatorio');
|
||||
}
|
||||
|
||||
$id = (int)$datos['id'];
|
||||
$accion = $datos['accion'];
|
||||
$comentario = $datos['comentario'] ?? '';
|
||||
|
||||
if ($accion === 'rechazada' && empty($comentario)) {
|
||||
jsonError('El motivo de rechazo es obligatorio');
|
||||
}
|
||||
|
||||
$om->cambiarEstado($id, $accion, $admin, $comentario);
|
||||
|
||||
$mensajes = [
|
||||
'en_revision' => 'Orden marcada en revisión',
|
||||
'autorizada' => 'Orden autorizada correctamente',
|
||||
'rechazada' => 'Orden rechazada',
|
||||
'en_domicilio'=> 'Orden marcada como en domicilio',
|
||||
'completada' => 'Orden completada',
|
||||
];
|
||||
|
||||
jsonOk(['id' => $id], $mensajes[$accion] ?? 'Estado actualizado');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
jsonError($e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* API: Crear orden médica desde conversación de WhatsApp
|
||||
*
|
||||
* GET ?solo_paciente=1&conversation_id=X → obtiene/crea paciente del contacto
|
||||
* POST { paciente_id, conversation_id, local_file, media_message_id, ... }
|
||||
* → crea lab_ordenes_medicas
|
||||
*/
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/../../classes/lab/ActividadAdmin.php';
|
||||
require_once __DIR__ . '/../../classes/lab/Paciente.php';
|
||||
require_once __DIR__ . '/../../classes/lab/OrdenMedica.php';
|
||||
|
||||
// Helpers inline (similar a _helpers.php pero sin incluir todas las clases de nuevo)
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
requireAuthentication();
|
||||
|
||||
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
||||
$db = Database::getInstance();
|
||||
|
||||
// ── GET: solo_paciente ─────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||
$userId = (int)($_GET['user_id'] ?? 0);
|
||||
|
||||
if (!$convId && !$userId) {
|
||||
echo json_encode(['success' => false, 'error' => 'conversation_id o user_id requerido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Obtener user_id y phone_number: por conversation_id o directamente por user_id
|
||||
if ($convId) {
|
||||
$conv = $db->fetch(
|
||||
'SELECT c.id, c.user_id, u.phone_number, u.name AS whatsapp_name
|
||||
FROM conversations c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.id = ?',
|
||||
[$convId]
|
||||
);
|
||||
} else {
|
||||
$conv = $db->fetch(
|
||||
'SELECT u.id AS user_id, u.phone_number, u.name AS whatsapp_name
|
||||
FROM users u
|
||||
WHERE u.id = ?',
|
||||
[$userId]
|
||||
);
|
||||
}
|
||||
|
||||
if (!$conv) {
|
||||
echo json_encode(['success' => false, 'error' => 'Conversación / usuario no encontrado']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pacienteRepo = new Paciente();
|
||||
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
||||
$paciente = $pacienteRepo->obtener($pacienteId);
|
||||
echo json_encode(['success' => true, 'paciente' => $paciente]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── POST: crear orden ──────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$input) {
|
||||
echo json_encode(['success' => false, 'error' => 'Datos inválidos']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pacienteId = (int)($input['paciente_id'] ?? 0);
|
||||
if (!$pacienteId) {
|
||||
echo json_encode(['success' => false, 'error' => 'paciente_id requerido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Construir datos de la orden
|
||||
$datos = [
|
||||
'paciente_id' => $pacienteId,
|
||||
'conversation_id' => !empty($input['conversation_id']) ? (int)$input['conversation_id'] : null,
|
||||
'local_file' => $input['local_file'] ?? null,
|
||||
'media_message_id' => $input['media_message_id'] ?? null,
|
||||
'medico_nombre' => $input['medico_nombre'] ?? null,
|
||||
'fecha_orden' => !empty($input['fecha_orden']) ? $input['fecha_orden'] : null,
|
||||
'examenes_solicitados'=> $input['examenes_solicitados']?? null,
|
||||
'requiere_ayuno' => isset($input['requiere_ayuno']) ? (int)$input['requiere_ayuno'] : 0,
|
||||
'horas_ayuno' => !empty($input['horas_ayuno']) ? (int)$input['horas_ayuno'] : null,
|
||||
'notas_admin' => $input['notas_admin'] ?? null,
|
||||
'estado' => 'pendiente',
|
||||
];
|
||||
|
||||
try {
|
||||
$orden = new OrdenMedica();
|
||||
$ordenId = $orden->crear($datos, $adminId);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'orden_id' => $ordenId,
|
||||
'message' => "Orden médica #$ordenId creada correctamente",
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log('[lab/crear_desde_whatsapp] ' . $e->getMessage());
|
||||
echo json_encode(['success' => false, 'error' => 'Error interno: ' . $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/create_enfermero_user.php
|
||||
* Crea o actualiza el usuario de acceso al sistema para una enfermera.
|
||||
* Solo administradores pueden llamar este endpoint.
|
||||
*
|
||||
* Body JSON:
|
||||
* { enfermera_id, username, password?, full_name? }
|
||||
* Si el usuario ya existe para esa enfermera_id, actualiza username/password.
|
||||
* Si password viene vacío en actualización, no cambia la contraseña.
|
||||
*
|
||||
* Returns:
|
||||
* { ok:true, message, user_id }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
requireMethod('POST');
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
$enfermeraId = (int)($datos['enfermera_id'] ?? 0);
|
||||
$username = trim($datos['username'] ?? '');
|
||||
$password = $datos['password'] ?? '';
|
||||
$fullName = trim($datos['full_name'] ?? '');
|
||||
|
||||
if (!$enfermeraId) jsonError('enfermera_id es obligatorio');
|
||||
if (!$username) jsonError('username es obligatorio');
|
||||
if (strlen($username) < 3) jsonError('El usuario debe tener al menos 3 caracteres');
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Verificar que la enfermera existe
|
||||
$enf = $db->fetch('SELECT id, nombre_completo FROM lab_enfermeras WHERE id = ?', [$enfermeraId]);
|
||||
if (!$enf) jsonError('Enfermera no encontrada', 404);
|
||||
|
||||
if (empty($fullName)) $fullName = $enf['nombre_completo'];
|
||||
|
||||
// ¿Ya existe un usuario vinculado a esta enfermera?
|
||||
$existente = $db->fetch(
|
||||
'SELECT id, username FROM admin_users WHERE enfermera_id = ? LIMIT 1',
|
||||
[$enfermeraId]
|
||||
);
|
||||
|
||||
if ($existente) {
|
||||
// --- ACTUALIZAR ---
|
||||
// Verificar que el nuevo username no esté en uso por OTRO usuario
|
||||
$conflicto = $db->fetch(
|
||||
'SELECT id FROM admin_users WHERE username = ? AND id != ?',
|
||||
[$username, $existente['id']]
|
||||
);
|
||||
if ($conflicto) jsonError("El usuario '$username' ya está en uso por otra cuenta");
|
||||
|
||||
$sets = ['username = ?', 'full_name = ?', 'is_active = 1', 'updated_at = NOW()'];
|
||||
$vals = [$username, $fullName];
|
||||
|
||||
if (!empty($password)) {
|
||||
if (strlen($password) < 6) jsonError('La contraseña debe tener al menos 6 caracteres');
|
||||
$sets[] = 'password_hash = ?';
|
||||
$vals[] = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$vals[] = $existente['id'];
|
||||
$db->execute(
|
||||
'UPDATE admin_users SET ' . implode(', ', $sets) . ' WHERE id = ?',
|
||||
$vals
|
||||
);
|
||||
|
||||
jsonOk(['user_id' => $existente['id']], 'Acceso actualizado correctamente');
|
||||
|
||||
} else {
|
||||
// --- CREAR ---
|
||||
if (empty($password)) jsonError('La contraseña es obligatoria para crear el acceso');
|
||||
if (strlen($password) < 6) jsonError('La contraseña debe tener al menos 6 caracteres');
|
||||
|
||||
// Verificar username único
|
||||
$conflicto = $db->fetch('SELECT id FROM admin_users WHERE username = ?', [$username]);
|
||||
if ($conflicto) jsonError("El usuario '$username' ya está en uso");
|
||||
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$db->execute(
|
||||
"INSERT INTO admin_users (username, password_hash, full_name, role, enfermera_id, is_active, created_at)
|
||||
VALUES (?, ?, ?, 'enfermero', ?, 1, NOW())",
|
||||
[$username, $hash, $fullName, $enfermeraId]
|
||||
);
|
||||
$userId = $db->lastInsertId();
|
||||
|
||||
jsonOk(['user_id' => $userId], 'Acceso creado correctamente');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/delete_lab_user.php
|
||||
* Elimina un usuario. No permite auto-eliminarse.
|
||||
* Solo admins.
|
||||
*
|
||||
* Body JSON: { id }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireAdmin();
|
||||
|
||||
$body = inputJson();
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) jsonError('ID de usuario requerido');
|
||||
|
||||
$selfId = adminId();
|
||||
if ($id === $selfId) jsonError('No puedes eliminar tu propio usuario');
|
||||
|
||||
$db = Database::getInstance();
|
||||
$user = $db->fetch("SELECT id, username FROM admin_users WHERE id = ?", [$id]);
|
||||
if (!$user) jsonError('Usuario no encontrado', 404);
|
||||
|
||||
$db->query("DELETE FROM admin_users WHERE id = ?", [$id]);
|
||||
|
||||
jsonOk([], "Usuario '{$user['username']}' eliminado correctamente");
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/delete_role.php
|
||||
* Elimina un rol no-sistema.
|
||||
* Solo admins.
|
||||
*
|
||||
* Body JSON: { id }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireAdmin();
|
||||
|
||||
$body = inputJson();
|
||||
$id = (int)($body['id'] ?? 0);
|
||||
if (!$id) jsonError('ID de rol requerido');
|
||||
|
||||
$db = Database::getInstance();
|
||||
$role = $db->fetch("SELECT id, is_system, slug FROM roles WHERE id = ?", [$id]);
|
||||
if (!$role) jsonError('Rol no encontrado', 404);
|
||||
if ($role['is_system']) jsonError('Los roles del sistema no se pueden eliminar');
|
||||
|
||||
// Reasignar usuarios que tengan este rol al rol admin (id=1) antes de borrar
|
||||
$db->query("UPDATE admin_users SET role_id = 1, role = 'admin' WHERE role_id = ?", [$id]);
|
||||
|
||||
// La FK ON DELETE CASCADE borra role_modules automáticamente
|
||||
$db->query("DELETE FROM roles WHERE id = ?", [$id]);
|
||||
|
||||
jsonOk([], 'Rol eliminado. Los usuarios afectados fueron reasignados al rol Administrador.');
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/** GET /api/lab/get_actividad.php — Log de trazabilidad del módulo */
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
try {
|
||||
$log = new ActividadAdmin();
|
||||
|
||||
$modulo = $_GET['modulo'] ?? '';
|
||||
$adminFilt = (int)($_GET['admin_id'] ?? 0);
|
||||
$entidad = (int)($_GET['entidad_id'] ?? 0);
|
||||
$limit = max(1, min(200, (int)($_GET['limit'] ?? 50)));
|
||||
|
||||
if ($adminFilt) {
|
||||
$data = $log->porAdmin($adminFilt, $limit);
|
||||
} elseif ($entidad && $modulo) {
|
||||
$data = $log->porEntidad($modulo, $entidad);
|
||||
} else {
|
||||
$data = $log->reciente($limit, $modulo);
|
||||
}
|
||||
|
||||
jsonOk(['data' => $data, 'total' => count($data)]);
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/** GET /api/lab/get_asignaciones.php — Asignaciones del día / por enfermera */
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
try {
|
||||
$asig = new Asignacion();
|
||||
|
||||
$fecha = $_GET['fecha'] ?? date('Y-m-d');
|
||||
$data = $asig->porFecha($fecha);
|
||||
$carga = $asig->cargaHoy();
|
||||
|
||||
jsonOk([
|
||||
'data' => $data,
|
||||
'total' => count($data),
|
||||
'fecha' => $fecha,
|
||||
'carga_hoy' => $carga,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/lab/get_config.php — config global del lab
|
||||
* GET /api/lab/get_config.php?forma=1 — config como objeto clave=>valor (para el builder)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
$db = Database::getInstance();
|
||||
$rows = $db->fetchAll('SELECT clave, valor FROM lab_config ORDER BY clave');
|
||||
|
||||
$cfg = [];
|
||||
foreach ($rows as $r) {
|
||||
$cfg[$r['clave']] = $r['valor'];
|
||||
}
|
||||
|
||||
jsonOk(['config' => $cfg]);
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/** GET /api/lab/get_domicilios.php — Lista de domicilios con filtros */
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
try {
|
||||
$dom = new Domicilio();
|
||||
|
||||
// Vista de un solo domicilio
|
||||
if (!empty($_GET['id'])) {
|
||||
$d = $dom->obtener((int)$_GET['id']);
|
||||
if (!$d) {
|
||||
jsonError('Domicilio no encontrado', 404);
|
||||
}
|
||||
jsonOk(['domicilio' => $d]);
|
||||
}
|
||||
|
||||
$filtros = array_filter([
|
||||
'fecha' => $_GET['fecha'] ?? '',
|
||||
'desde' => $_GET['desde'] ?? '',
|
||||
'hasta' => $_GET['hasta'] ?? '',
|
||||
'estado' => $_GET['estado'] ?? '',
|
||||
'enfermera_id' => (int)($_GET['enfermera_id'] ?? 0) ?: null,
|
||||
'paciente_id' => (int)($_GET['paciente_id'] ?? 0) ?: null,
|
||||
], fn($v) => $v !== null && $v !== '');
|
||||
|
||||
$pagina = max(1, (int)($_GET['page'] ?? 1));
|
||||
$por = max(1, min(200, (int)($_GET['limit'] ?? 30)));
|
||||
|
||||
$resultado = $dom->listar($filtros, $pagina, $por);
|
||||
|
||||
// Incluir resumen del día
|
||||
if (empty($_GET['no_stats'])) {
|
||||
$resultado['estadisticas_hoy'] = $dom->estadisticasHoy();
|
||||
$resultado['sin_asignar_hoy'] = count($dom->sinAsignar());
|
||||
}
|
||||
|
||||
jsonOk($resultado);
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
/** GET /api/lab/get_enfermeras.php — Lista de enfermeras */
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
try {
|
||||
$enf = new Enfermera();
|
||||
|
||||
// Perfil + agenda de una sola enfermera
|
||||
if (!empty($_GET['id'])) {
|
||||
$id = (int)$_GET['id'];
|
||||
$e = $enf->obtener($id);
|
||||
if (!$e) {
|
||||
jsonError('Enfermera no encontrada', 404);
|
||||
}
|
||||
$fecha = $_GET['fecha'] ?? date('Y-m-d');
|
||||
$e['agenda'] = $enf->agenda($id, $fecha);
|
||||
|
||||
// Incluir info de acceso al sistema (si tiene usuario creado)
|
||||
$db = Database::getInstance();
|
||||
$u = $db->fetch(
|
||||
'SELECT id, username FROM admin_users WHERE enfermera_id = ? AND role = "enfermero" LIMIT 1',
|
||||
[$id]
|
||||
);
|
||||
$e['usuario_acceso'] = $u ?: null;
|
||||
|
||||
jsonOk(['enfermera' => $e]);
|
||||
}
|
||||
|
||||
$soloActivas = !isset($_GET['todas']) || $_GET['todas'] !== '1';
|
||||
$lista = $enf->listar($soloActivas);
|
||||
|
||||
// Enriquecer con username de acceso para cada enfermera
|
||||
$db = Database::getInstance();
|
||||
foreach ($lista as &$item) {
|
||||
$u = $db->fetch(
|
||||
'SELECT username FROM admin_users WHERE enfermera_id = ? AND is_active = 1 LIMIT 1',
|
||||
[$item['id']]
|
||||
);
|
||||
$item['username_acceso'] = $u['username'] ?? null;
|
||||
}
|
||||
unset($item);
|
||||
|
||||
jsonOk(['data' => $lista, 'total' => count($lista)]);
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/lab/get_formularios.php — Lista de plantillas
|
||||
* GET /api/lab/get_formularios.php?id=X — Plantilla específica
|
||||
* GET /api/lab/get_formularios.php?envios=1&formulario_id=X — Envíos
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
try {
|
||||
$form = new Formulario();
|
||||
|
||||
// Lista de envíos
|
||||
if (!empty($_GET['envios'])) {
|
||||
$filtros = [];
|
||||
if (!empty($_GET['formulario_id'])) $filtros['formulario_id'] = (int)$_GET['formulario_id'];
|
||||
if (!empty($_GET['paciente_id'])) $filtros['paciente_id'] = (int)$_GET['paciente_id'];
|
||||
if (!empty($_GET['estado'])) $filtros['estado'] = $_GET['estado'];
|
||||
// Enfermero solo ve los suyos
|
||||
if (userRole() === 'enfermero') {
|
||||
$filtros['enviado_por'] = adminId();
|
||||
}
|
||||
jsonOk(['data' => $form->listarEnvios($filtros)]);
|
||||
}
|
||||
|
||||
// Plantilla específica
|
||||
if (!empty($_GET['id'])) {
|
||||
$f = $form->obtener((int)$_GET['id']);
|
||||
if (!$f) jsonError('Formulario no encontrado', 404);
|
||||
jsonOk(['formulario' => $f]);
|
||||
}
|
||||
|
||||
// Lista de plantillas
|
||||
$todas = isset($_GET['todas']) && userRole() === 'admin';
|
||||
jsonOk(['data' => $form->listar(!$todas)]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/lab/get_lab_users.php
|
||||
* Devuelve todos los usuarios del sistema con información de rol.
|
||||
* Solo admins.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$users = $db->fetchAll("
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.full_name,
|
||||
u.email,
|
||||
u.is_active,
|
||||
u.role,
|
||||
u.role_id,
|
||||
u.enfermera_id,
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
r.name AS role_name,
|
||||
r.color AS role_color,
|
||||
r.slug AS role_slug,
|
||||
e.nombre_completo AS enfermera_nombre
|
||||
FROM admin_users u
|
||||
LEFT JOIN roles r ON r.id = u.role_id
|
||||
LEFT JOIN lab_enfermeras e ON e.id = u.enfermera_id
|
||||
ORDER BY u.id ASC
|
||||
");
|
||||
|
||||
foreach ($users as &$u) {
|
||||
$u['is_active'] = (bool)$u['is_active'];
|
||||
$u['role_name'] = $u['role_name'] ?? ucfirst($u['role'] ?? 'admin');
|
||||
$u['role_color'] = $u['role_color'] ?? '#0d6efd';
|
||||
}
|
||||
unset($u);
|
||||
|
||||
jsonOk(['users' => $users]);
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/** GET /api/lab/get_ordenes.php — Lista paginada de órdenes médicas con filtros */
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
try {
|
||||
$om = new OrdenMedica();
|
||||
|
||||
$filtros = array_filter([
|
||||
'estado' => $_GET['estado'] ?? '',
|
||||
'paciente_id' => (int)($_GET['paciente_id'] ?? 0) ?: null,
|
||||
'desde' => $_GET['desde'] ?? '',
|
||||
'hasta' => $_GET['hasta'] ?? '',
|
||||
'busqueda' => trim($_GET['busqueda'] ?? $_GET['search'] ?? ''),
|
||||
], fn($v) => $v !== null && $v !== '');
|
||||
|
||||
$pagina = max(1, (int)($_GET['page'] ?? 1));
|
||||
$por = max(1, min(100, (int)($_GET['limit'] ?? 30)));
|
||||
|
||||
// Vista de una sola orden
|
||||
if (!empty($_GET['id'])) {
|
||||
$orden = $om->obtener((int)$_GET['id']);
|
||||
if (!$orden) {
|
||||
jsonError('Orden no encontrada', 404);
|
||||
}
|
||||
jsonOk(['orden' => $orden]);
|
||||
}
|
||||
|
||||
$resultado = $om->listar($filtros, $pagina, $por);
|
||||
|
||||
// Incluir también el contador por estado para el dashboard
|
||||
if (!isset($_GET['no_counts'])) {
|
||||
$resultado['contadores'] = $om->contadorPorEstado();
|
||||
}
|
||||
|
||||
jsonOk($resultado);
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
/** GET /api/lab/get_pacientes.php — Lista paginada de pacientes */
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
try {
|
||||
$pac = new Paciente();
|
||||
|
||||
$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)));
|
||||
|
||||
jsonOk($pac->listar($busqueda, $pagina, $por));
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/lab/get_roles.php
|
||||
* Devuelve todos los roles con sus módulos asignados.
|
||||
* Solo admins.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$roles = $db->fetchAll("SELECT id, name, slug, description, color, is_system, created_at FROM roles ORDER BY id ASC");
|
||||
|
||||
foreach ($roles as &$role) {
|
||||
$mods = $db->fetchAll(
|
||||
"SELECT module_slug FROM role_modules WHERE role_id = ? ORDER BY module_slug",
|
||||
[$role['id']]
|
||||
);
|
||||
$role['modules'] = array_column($mods, 'module_slug');
|
||||
$role['is_system'] = (bool)$role['is_system'];
|
||||
$role['user_count'] = (int)$db->fetch(
|
||||
"SELECT COUNT(*) AS c FROM admin_users WHERE role_id = ?",
|
||||
[$role['id']]
|
||||
)['c'];
|
||||
}
|
||||
unset($role);
|
||||
|
||||
jsonOk(['roles' => $roles]);
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/lab/get_stats.php — Estadísticas del dashboard del módulo
|
||||
* Devuelve contadores, pendientes urgentes, agenda hoy y carga de enfermeras.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
try {
|
||||
$om = new OrdenMedica();
|
||||
$dom = new Domicilio();
|
||||
$asig = new Asignacion();
|
||||
$pac = new Paciente();
|
||||
$db = Database::getInstance();
|
||||
|
||||
// ── Órdenes ──────────────────────────────────────────────────────────────
|
||||
$estadosOrdenes = $om->contadorPorEstado();
|
||||
$pendientesViejos = $om->pendientesAntiguos(5);
|
||||
|
||||
// ── Domicilios hoy ───────────────────────────────────────────────────────
|
||||
$estDomHoy = $dom->estadisticasHoy();
|
||||
$sinAsignarHoy = $dom->sinAsignar();
|
||||
|
||||
// ── Carga de enfermeras hoy ──────────────────────────────────────────────
|
||||
$cargaEnfermeras = $asig->cargaHoy();
|
||||
|
||||
// ── Totales generales ────────────────────────────────────────────────────
|
||||
$totalPacientes = $db->fetch('SELECT COUNT(*) AS n FROM lab_pacientes WHERE is_active = 1')['n'] ?? 0;
|
||||
$totalEnfermeras = $db->fetch('SELECT COUNT(*) AS n FROM lab_enfermeras WHERE is_active = 1')['n'] ?? 0;
|
||||
|
||||
// ── Órdenes de hoy ───────────────────────────────────────────────────────
|
||||
$ordenesHoy = $db->fetch(
|
||||
'SELECT COUNT(*) AS n FROM lab_ordenes_medicas WHERE DATE(created_at) = CURDATE()'
|
||||
)['n'] ?? 0;
|
||||
|
||||
// ── Actividad reciente ───────────────────────────────────────────────────
|
||||
$actividadReciente = (new ActividadAdmin())->reciente(10);
|
||||
|
||||
jsonOk([
|
||||
'ordenes' => [
|
||||
'por_estado' => $estadosOrdenes,
|
||||
'pendientes_hoy' => (int)($estadosOrdenes['pendiente'] ?? 0),
|
||||
'en_revision_hoy' => (int)($estadosOrdenes['en_revision'] ?? 0),
|
||||
'pendientes_viejos'=> $pendientesViejos,
|
||||
'total_hoy' => (int)$ordenesHoy,
|
||||
],
|
||||
'domicilios' => [
|
||||
'por_estado' => $estDomHoy,
|
||||
'total_hoy' => array_sum($estDomHoy),
|
||||
'sin_asignar' => count($sinAsignarHoy),
|
||||
'lista_sin_asignar' => $sinAsignarHoy,
|
||||
],
|
||||
'enfermeras' => [
|
||||
'activas' => (int)$totalEnfermeras,
|
||||
'carga_hoy' => $cargaEnfermeras,
|
||||
],
|
||||
'generales' => [
|
||||
'total_pacientes' => (int)$totalPacientes,
|
||||
],
|
||||
'actividad_reciente' => $actividadReciente,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /api/lab/my_agenda.php
|
||||
* Devuelve la agenda del enfermero autenticado (o de cualquier enfermera para admins).
|
||||
*
|
||||
* Query params:
|
||||
* enfermera_id (int) — Requerido para admins. Enfermeros usan automáticamente el suyo.
|
||||
* fecha (Y-m-d) — Por defecto: hoy.
|
||||
* rango_inicio (Y-m-d) — Para vista semanal/mensual.
|
||||
* rango_fin (Y-m-d)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
|
||||
try {
|
||||
// Determinar enfermera_id
|
||||
if (userRole() === 'enfermero') {
|
||||
$eid = enfermeraId();
|
||||
if (!$eid) {
|
||||
jsonError('Tu usuario no tiene una enfermera vinculada. Contacta al administrador.');
|
||||
}
|
||||
} else {
|
||||
$eid = (int)($_GET['enfermera_id'] ?? 0);
|
||||
if (!$eid) {
|
||||
jsonError('enfermera_id requerido');
|
||||
}
|
||||
}
|
||||
|
||||
$enf = new Enfermera();
|
||||
$fecha = $_GET['fecha'] ?? date('Y-m-d');
|
||||
|
||||
// Agenda del día con pleno detalle
|
||||
$agenda = $enf->agenda($eid, $fecha);
|
||||
|
||||
// Enriquecer con servicios extra y estado de asignación
|
||||
$db = Database::getInstance();
|
||||
foreach ($agenda as &$item) {
|
||||
$item['servicios_extra'] = $db->fetchAll(
|
||||
"SELECT * FROM lab_servicios_extra WHERE domicilio_id = ? ORDER BY created_at ASC",
|
||||
[(int)$item['domicilio_id']]
|
||||
);
|
||||
// Info completa del domicilio (notas, indicaciones)
|
||||
$extra = $db->fetch(
|
||||
"SELECT notas_admin, indicaciones_dir, barrio, ciudad FROM lab_domicilios WHERE id = ?",
|
||||
[(int)$item['domicilio_id']]
|
||||
);
|
||||
if ($extra) $item = array_merge($item, $extra);
|
||||
}
|
||||
unset($item);
|
||||
|
||||
// Resumen rápido
|
||||
$totales = array_count_values(array_column($agenda, 'domicilio_estado'));
|
||||
|
||||
jsonOk([
|
||||
'enfermera_id' => $eid,
|
||||
'fecha' => $fecha,
|
||||
'agenda' => $agenda,
|
||||
'total' => count($agenda),
|
||||
'totales' => $totales,
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_asignacion.php
|
||||
* Asigna (o reasigna) una enfermera a un domicilio.
|
||||
* Body JSON:
|
||||
* { domicilio_id, enfermera_id, notas? }
|
||||
* -- Para liberar/completar:
|
||||
* { id, accion: 'liberar'|'completar', motivo? }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
$asig = new Asignacion();
|
||||
$admin = adminId();
|
||||
|
||||
// Liberar o completar
|
||||
if (!empty($datos['id']) && !empty($datos['accion'])) {
|
||||
$id = (int)$datos['id'];
|
||||
if ($datos['accion'] === 'liberar') {
|
||||
$asig->liberar($id, $admin, $datos['motivo'] ?? '');
|
||||
jsonOk(['id' => $id], 'Asignación liberada');
|
||||
}
|
||||
if ($datos['accion'] === 'completar') {
|
||||
$asig->completar($id, $admin);
|
||||
jsonOk(['id' => $id], 'Asignación completada');
|
||||
}
|
||||
jsonError('Acción no reconocida');
|
||||
}
|
||||
|
||||
// Asignar / reasignar
|
||||
foreach (['domicilio_id', 'enfermera_id'] as $req) {
|
||||
if (empty($datos[$req])) {
|
||||
jsonError("El campo $req es obligatorio");
|
||||
}
|
||||
}
|
||||
|
||||
$id = $asig->asignar(
|
||||
(int)$datos['domicilio_id'],
|
||||
(int)$datos['enfermera_id'],
|
||||
$admin,
|
||||
$datos['notas'] ?? ''
|
||||
);
|
||||
|
||||
jsonOk(['id' => $id], 'Enfermera asignada correctamente');
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_config.php — Guardar configuración global del lab
|
||||
* Solo admin.
|
||||
* Body JSON: { empresa_nombre, empresa_subtitulo, empresa_direccion, empresa_telefono,
|
||||
* empresa_email, empresa_ciudad, doc_color, doc_logo_base64, doc_pie_pagina }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireAdmin();
|
||||
|
||||
$datos = inputJson();
|
||||
$db = Database::getInstance();
|
||||
|
||||
$permitidas = [
|
||||
'empresa_nombre', 'empresa_subtitulo', 'empresa_direccion',
|
||||
'empresa_telefono', 'empresa_email', 'empresa_ciudad',
|
||||
'doc_color', 'doc_logo_base64', 'doc_pie_pagina',
|
||||
];
|
||||
|
||||
$guardadas = 0;
|
||||
foreach ($permitidas as $clave) {
|
||||
if (array_key_exists($clave, $datos)) {
|
||||
$db->query(
|
||||
'INSERT INTO lab_config(clave, valor) VALUES(?,?) ON DUPLICATE KEY UPDATE valor=?',
|
||||
[$clave, $datos[$clave], $datos[$clave]]
|
||||
);
|
||||
$guardadas++;
|
||||
}
|
||||
}
|
||||
|
||||
jsonOk(['guardadas' => $guardadas], 'Configuración guardada');
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_domicilio.php
|
||||
* Crea, actualiza o cambia estado de un domicilio.
|
||||
* Body JSON:
|
||||
* { id?, paciente_id, orden_id?, direccion, ciudad?, barrio?,
|
||||
* indicaciones_dir?, fecha_programada, hora_programada?,
|
||||
* tipo_servicio?, estado?,
|
||||
* -- Para cambio de estado solamente:
|
||||
* solo_estado?: true, nuevo_estado?, motivo_cancelacion?, ... }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
$dom = new Domicilio();
|
||||
$admin = adminId();
|
||||
|
||||
// Solo cambio de estado
|
||||
if (!empty($datos['id']) && !empty($datos['solo_estado'])) {
|
||||
$id = (int)$datos['id'];
|
||||
$nuevoEstado = $datos['nuevo_estado'] ?? '';
|
||||
$extras = array_filter([
|
||||
'motivo_cancelacion' => $datos['motivo_cancelacion'] ?? null,
|
||||
'fecha_reprogramada' => $datos['fecha_reprogramada'] ?? null,
|
||||
'hora_llegada' => $datos['hora_llegada'] ?? null,
|
||||
'hora_salida' => $datos['hora_salida'] ?? null,
|
||||
'observaciones' => $datos['observaciones'] ?? null,
|
||||
'muestras_tomadas' => $datos['muestras_tomadas'] ?? null,
|
||||
]);
|
||||
$dom->cambiarEstado($id, $nuevoEstado, $admin, $extras);
|
||||
jsonOk(['id' => $id], 'Estado actualizado');
|
||||
}
|
||||
|
||||
if (!empty($datos['id'])) {
|
||||
// Actualización general
|
||||
$id = (int)$datos['id'];
|
||||
unset($datos['id'], $datos['solo_estado']);
|
||||
// Normalizar orden_id: vacío o 0 → null
|
||||
if (isset($datos['orden_id']) && ($datos['orden_id'] === '' || (int)$datos['orden_id'] === 0)) {
|
||||
$datos['orden_id'] = null;
|
||||
}
|
||||
$dom->actualizar($id, $datos, $admin);
|
||||
jsonOk(['id' => $id], 'Domicilio actualizado correctamente');
|
||||
} else {
|
||||
// Creación
|
||||
foreach (['paciente_id', 'direccion', 'fecha_programada'] as $req) {
|
||||
if (empty($datos[$req])) {
|
||||
jsonError("El campo $req es obligatorio");
|
||||
}
|
||||
}
|
||||
// Normalizar orden_id: vacío o 0 → null (evita FK constraint)
|
||||
if (isset($datos['orden_id']) && ($datos['orden_id'] === '' || (int)$datos['orden_id'] === 0)) {
|
||||
$datos['orden_id'] = null;
|
||||
}
|
||||
// Si el creador es un enfermero y no vino enfermera_id, auto-asignarlo desde la sesión
|
||||
if (empty($datos['enfermera_id']) && userRole() === 'enfermero') {
|
||||
$eid = enfermeraId();
|
||||
if ($eid) $datos['enfermera_id'] = $eid;
|
||||
}
|
||||
$id = $dom->crear($datos, $admin);
|
||||
|
||||
// Crear asignación automática si se indicó enfermera_id
|
||||
if (!empty($datos['enfermera_id'])) {
|
||||
$asig = new Asignacion();
|
||||
$asig->asignar($id, (int)$datos['enfermera_id'], $admin);
|
||||
}
|
||||
|
||||
jsonOk(['id' => $id], 'Domicilio creado correctamente');
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
jsonError($e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_enfermera.php
|
||||
* Crea o actualiza una enfermera.
|
||||
* Body JSON:
|
||||
* { id?, nombre_completo, numero_documento, tipo_documento?,
|
||||
* telefono, telefono_alt?, email?, zona?, notas?, is_active? }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
$enf = new Enfermera();
|
||||
$admin = adminId();
|
||||
|
||||
if (!empty($datos['id'])) {
|
||||
$id = (int)$datos['id'];
|
||||
unset($datos['id']);
|
||||
|
||||
// Desactivar
|
||||
if (isset($datos['is_active']) && $datos['is_active'] == 0) {
|
||||
$enf->desactivar($id, $admin);
|
||||
jsonOk(['id' => $id], 'Enfermera desactivada');
|
||||
}
|
||||
|
||||
$enf->actualizar($id, $datos, $admin);
|
||||
jsonOk(['id' => $id], 'Enfermera actualizada correctamente');
|
||||
} else {
|
||||
foreach (['nombre_completo', 'numero_documento', 'telefono'] as $req) {
|
||||
if (empty($datos[$req])) {
|
||||
jsonError("El campo $req es obligatorio");
|
||||
}
|
||||
}
|
||||
$id = $enf->crear($datos, $admin);
|
||||
jsonOk(['id' => $id], 'Enfermera registrada correctamente');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_formulario.php
|
||||
* Solo admins pueden crear/editar plantillas.
|
||||
*
|
||||
* Body JSON para crear: { nombre, descripcion?, categoria?, esquema, permite_firma?, requiere_firma? }
|
||||
* Body JSON para editar: { id, ...mismos campos... }
|
||||
* Body JSON para borrar: { id, borrar: true }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireAdmin(); // enfermeros NO pueden diseñar formularios
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
$form = new Formulario();
|
||||
$admin = adminId();
|
||||
|
||||
// Borrar (soft delete)
|
||||
if (!empty($datos['id']) && !empty($datos['borrar'])) {
|
||||
$form->actualizar((int)$datos['id'], ['is_active' => 0], $admin);
|
||||
jsonOk(['id' => (int)$datos['id']], 'Formulario eliminado');
|
||||
}
|
||||
|
||||
// Editar
|
||||
if (!empty($datos['id'])) {
|
||||
$id = (int)$datos['id'];
|
||||
unset($datos['id']);
|
||||
$form->actualizar($id, $datos, $admin);
|
||||
jsonOk(['id' => $id], 'Formulario actualizado');
|
||||
}
|
||||
|
||||
// Crear
|
||||
if (empty($datos['nombre'])) jsonError('El nombre es obligatorio');
|
||||
if (empty($datos['esquema'])) jsonError('El esquema es obligatorio');
|
||||
|
||||
$id = $form->crear($datos, $admin);
|
||||
jsonOk(['id' => $id], 'Formulario creado correctamente');
|
||||
|
||||
} catch (InvalidArgumentException $e) {
|
||||
jsonError($e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_lab_user.php
|
||||
* Crea o actualiza un usuario del sistema.
|
||||
* Solo admins.
|
||||
*
|
||||
* Body JSON:
|
||||
* { id?, username, full_name, email?, password?, role_id, is_active?, enfermera_id? }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireAdmin();
|
||||
|
||||
$body = inputJson();
|
||||
$id = isset($body['id']) ? (int)$body['id'] : null;
|
||||
$username = trim($body['username'] ?? '');
|
||||
$fullName = trim($body['full_name'] ?? '');
|
||||
$email = trim($body['email'] ?? '');
|
||||
$password = $body['password'] ?? '';
|
||||
$roleId = isset($body['role_id']) ? (int)$body['role_id'] : null;
|
||||
$isActive = isset($body['is_active']) ? (int)(bool)$body['is_active'] : 1;
|
||||
$enfermeraId = isset($body['enfermera_id']) && $body['enfermera_id'] !== '' && $body['enfermera_id'] !== null
|
||||
? (int)$body['enfermera_id'] : null;
|
||||
|
||||
if (!$username) jsonError('El nombre de usuario es obligatorio');
|
||||
if (!$fullName) jsonError('El nombre completo es obligatorio');
|
||||
if (!$roleId) jsonError('Debes seleccionar un rol');
|
||||
|
||||
// Validar username (solo alfanumérico + guión bajo)
|
||||
if (!preg_match('/^[a-zA-Z0-9_\.]{3,50}$/', $username)) {
|
||||
jsonError('El username solo puede tener letras, números, puntos y guiones bajos (3-50 caracteres)');
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Obtener slug del rol
|
||||
$role = $db->fetch("SELECT id, slug FROM roles WHERE id = ?", [$roleId]);
|
||||
if (!$role) jsonError('Rol no encontrado', 404);
|
||||
$roleSlug = $role['slug'];
|
||||
|
||||
// Si el rol es enfermero y no hay enfermera_id, buscar si tiene ya uno vinculado
|
||||
// (se puede crear sin vincular y vincularlo después desde la pantalla de Enfermeras)
|
||||
|
||||
if ($id) {
|
||||
// ── Actualizar ──────────────────────────────────────────
|
||||
$existing = $db->fetch("SELECT id, username FROM admin_users WHERE id = ?", [$id]);
|
||||
if (!$existing) jsonError('Usuario no encontrado', 404);
|
||||
|
||||
// Verificar duplicado de username (excluir el propio)
|
||||
$dup = $db->fetch("SELECT id FROM admin_users WHERE username = ? AND id != ?", [$username, $id]);
|
||||
if ($dup) jsonError('Ese nombre de usuario ya está en uso');
|
||||
|
||||
$params = [$username, $fullName, $email ?: null, $roleId, $roleSlug, $isActive, $enfermeraId, $id];
|
||||
$sql = "UPDATE admin_users
|
||||
SET username=?, full_name=?, email=?, role_id=?, role=?, is_active=?, enfermera_id=?, updated_at=NOW()
|
||||
WHERE id=?";
|
||||
|
||||
if ($password) {
|
||||
if (strlen($password) < 6) jsonError('La contraseña debe tener al menos 6 caracteres');
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT);
|
||||
$sql = "UPDATE admin_users
|
||||
SET username=?, full_name=?, email=?, role_id=?, role=?, is_active=?, enfermera_id=?, password_hash=?, updated_at=NOW()
|
||||
WHERE id=?";
|
||||
$params = [$username, $fullName, $email ?: null, $roleId, $roleSlug, $isActive, $enfermeraId, $hash, $id];
|
||||
}
|
||||
|
||||
$db->query($sql, $params);
|
||||
jsonOk(['id' => $id], 'Usuario actualizado correctamente');
|
||||
|
||||
} else {
|
||||
// ── Crear ────────────────────────────────────────────────
|
||||
if (!$password) jsonError('La contraseña es obligatoria al crear un usuario');
|
||||
if (strlen($password) < 6) jsonError('La contraseña debe tener al menos 6 caracteres');
|
||||
|
||||
$dup = $db->fetch("SELECT id FROM admin_users WHERE username = ?", [$username]);
|
||||
if ($dup) jsonError('Ese nombre de usuario ya está en uso');
|
||||
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT);
|
||||
|
||||
$db->query(
|
||||
"INSERT INTO admin_users (username, full_name, email, password_hash, role_id, role, is_active, enfermera_id)
|
||||
VALUES (?,?,?,?,?,?,?,?)",
|
||||
[$username, $fullName, $email ?: null, $hash, $roleId, $roleSlug, $isActive, $enfermeraId]
|
||||
);
|
||||
$newId = $db->lastInsertId();
|
||||
jsonOk(['id' => $newId], 'Usuario creado correctamente');
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_orden.php
|
||||
* Crea o actualiza datos de una orden médica (sin cambiar estado).
|
||||
* Body JSON:
|
||||
* { id?, paciente_id, conversation_id?, whatsapp_media_id?, local_file?,
|
||||
* medico_nombre?, medico_registro?, fecha_orden?, diagnostico?,
|
||||
* examenes_solicitados?, requiere_ayuno?, horas_ayuno?,
|
||||
* indicaciones?, notas_admin? }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
$om = new OrdenMedica();
|
||||
$admin = adminId();
|
||||
|
||||
if (!empty($datos['id'])) {
|
||||
$id = (int)$datos['id'];
|
||||
unset($datos['id']);
|
||||
$om->actualizar($id, $datos, $admin);
|
||||
jsonOk(['id' => $id], 'Orden actualizada correctamente');
|
||||
} else {
|
||||
if (empty($datos['paciente_id'])) {
|
||||
jsonError('El campo paciente_id es obligatorio');
|
||||
}
|
||||
$id = $om->crear($datos, $admin);
|
||||
jsonOk(['id' => $id], 'Orden creada correctamente');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_paciente.php
|
||||
* Crea o actualiza un paciente.
|
||||
* Body JSON:
|
||||
* { id?, nombre_completo, numero_documento?, tipo_documento?, telefono?,
|
||||
* email?, fecha_nacimiento?, genero?, direccion?, ciudad?, barrio?,
|
||||
* eps?, notas_admin?, user_id? }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
$pac = new Paciente();
|
||||
$admin = adminId();
|
||||
|
||||
if (!empty($datos['id'])) {
|
||||
// Actualización
|
||||
$id = (int)$datos['id'];
|
||||
unset($datos['id']);
|
||||
$pac->actualizar($id, $datos, $admin);
|
||||
jsonOk(['id' => $id], 'Paciente actualizado correctamente');
|
||||
} else {
|
||||
// Creación — nombre obligatorio
|
||||
if (empty($datos['nombre_completo'])) {
|
||||
jsonError('El campo nombre_completo es obligatorio');
|
||||
}
|
||||
$id = $pac->crear($datos, $admin);
|
||||
jsonOk(['id' => $id], 'Paciente creado correctamente');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_role.php
|
||||
* Crea o actualiza un rol y sus módulos.
|
||||
* Solo admins.
|
||||
*
|
||||
* Body JSON:
|
||||
* { id?, name, slug, description?, color?, modules: [slug, ...] }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
requireAdmin();
|
||||
|
||||
$body = inputJson();
|
||||
|
||||
$id = isset($body['id']) ? (int)$body['id'] : null;
|
||||
$name = trim($body['name'] ?? '');
|
||||
$slug = trim($body['slug'] ?? '');
|
||||
$description = trim($body['description'] ?? '');
|
||||
$color = trim($body['color'] ?? '#6c757d');
|
||||
$modules = $body['modules'] ?? [];
|
||||
|
||||
if (!$name) jsonError('El nombre del rol es obligatorio');
|
||||
if (!$slug) jsonError('El slug del rol es obligatorio');
|
||||
if (!preg_match('/^[a-z0-9_\-]+$/', $slug)) jsonError('El slug solo puede contener letras minúsculas, números, guiones y guiones bajos');
|
||||
if (!is_array($modules)) jsonError('modules debe ser un arreglo');
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Verificar duplicado de slug
|
||||
$existing = $db->fetch("SELECT id, is_system FROM roles WHERE slug = ?", [$slug]);
|
||||
if ($existing && (!$id || $existing['id'] !== $id)) {
|
||||
jsonError('Ya existe un rol con ese slug');
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
// Actualizar
|
||||
$role = $db->fetch("SELECT id, is_system FROM roles WHERE id = ?", [$id]);
|
||||
if (!$role) jsonError('Rol no encontrado', 404);
|
||||
|
||||
$db->query(
|
||||
"UPDATE roles SET name=?, slug=?, description=?, color=?, updated_at=NOW() WHERE id=?",
|
||||
[$name, $slug, $description, $color, $id]
|
||||
);
|
||||
// Reconstruir módulos solo si no es sistema, o siempre (admin puede editar módulos)
|
||||
$db->query("DELETE FROM role_modules WHERE role_id = ?", [$id]);
|
||||
} else {
|
||||
// Crear
|
||||
$db->query(
|
||||
"INSERT INTO roles (name, slug, description, color) VALUES (?,?,?,?)",
|
||||
[$name, $slug, $description, $color]
|
||||
);
|
||||
$id = $db->lastInsertId();
|
||||
}
|
||||
|
||||
// Insertar módulos
|
||||
foreach ($modules as $mod) {
|
||||
$mod = trim((string)$mod);
|
||||
if ($mod) {
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO role_modules (role_id, module_slug) VALUES (?,?)",
|
||||
[$id, $mod]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
jsonOk(['id' => $id], $id ? 'Rol actualizado' : 'Rol creado');
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/save_servicio_extra.php
|
||||
* El enfermero agrega (o actualiza/elimina) un servicio extra a un domicilio.
|
||||
*
|
||||
* Body JSON:
|
||||
* { domicilio_id, descripcion, tipo, notas?, requiere_pago?, valor? } -- crear
|
||||
* { id, descripcion?, tipo?, notas?, requiere_pago?, valor? } -- editar
|
||||
* { id, eliminar: true } -- borrar
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
$TIPOS_VALIDOS = ['inyeccion','cura','nebulizacion','toma_muestra',
|
||||
'tension_arterial','glucometria','otro'];
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
$db = Database::getInstance();
|
||||
|
||||
// ── Eliminar ───────────────────────────────────────────────────────────
|
||||
if (!empty($datos['id']) && !empty($datos['eliminar'])) {
|
||||
$se = $db->fetch('SELECT * FROM lab_servicios_extra WHERE id = ?', [(int)$datos['id']]);
|
||||
if (!$se) jsonError('Servicio no encontrado', 404);
|
||||
if (userRole() === 'enfermero') {
|
||||
requireEnfermeroAccess((int)$se['realizado_por']);
|
||||
}
|
||||
$db->delete('lab_servicios_extra', 'id = ?', [(int)$datos['id']]);
|
||||
jsonOk(['id' => (int)$datos['id']], 'Servicio eliminado');
|
||||
}
|
||||
|
||||
// ── Editar ─────────────────────────────────────────────────────────────
|
||||
if (!empty($datos['id'])) {
|
||||
$id = (int)$datos['id'];
|
||||
$se = $db->fetch('SELECT * FROM lab_servicios_extra WHERE id = ?', [$id]);
|
||||
if (!$se) jsonError('Servicio no encontrado', 404);
|
||||
if (userRole() === 'enfermero') {
|
||||
requireEnfermeroAccess((int)$se['realizado_por']);
|
||||
}
|
||||
$permitidos = ['descripcion','tipo','notas','requiere_pago','valor'];
|
||||
$campos = array_intersect_key($datos, array_flip($permitidos));
|
||||
if (isset($campos['tipo']) && !in_array($campos['tipo'], $TIPOS_VALIDOS)) {
|
||||
jsonError('Tipo de servicio inválido');
|
||||
}
|
||||
$db->update('lab_servicios_extra', $campos, 'id = ?', [$id]);
|
||||
jsonOk(['id' => $id], 'Servicio actualizado');
|
||||
}
|
||||
|
||||
// ── Crear ──────────────────────────────────────────────────────────────
|
||||
foreach (['domicilio_id', 'descripcion', 'tipo'] as $req) {
|
||||
if (empty($datos[$req])) jsonError("El campo $req es obligatorio");
|
||||
}
|
||||
if (!in_array($datos['tipo'], $TIPOS_VALIDOS)) {
|
||||
jsonError('Tipo de servicio inválido');
|
||||
}
|
||||
|
||||
// Verificar que el domicilio existe y que el enfermero le pertenece
|
||||
$domId = (int)$datos['domicilio_id'];
|
||||
if (userRole() === 'enfermero') {
|
||||
$eid = enfermeraId();
|
||||
$asig = $db->fetch(
|
||||
'SELECT a.id FROM lab_asignaciones a WHERE a.domicilio_id = ? AND a.enfermera_id = ?',
|
||||
[$domId, $eid]
|
||||
);
|
||||
if (!$asig) jsonError('No tienes permiso para este domicilio', 403);
|
||||
}
|
||||
|
||||
$id = $db->insert('lab_servicios_extra', [
|
||||
'domicilio_id' => $domId,
|
||||
'descripcion' => trim($datos['descripcion']),
|
||||
'tipo' => $datos['tipo'],
|
||||
'notas' => $datos['notas'] ?? null,
|
||||
'requiere_pago' => !empty($datos['requiere_pago']) ? 1 : 0,
|
||||
'valor' => !empty($datos['valor']) ? (float)$datos['valor'] : null,
|
||||
'realizado_por' => enfermeraId() ?? (userRole() === 'admin' ? null : null),
|
||||
]);
|
||||
|
||||
jsonOk(['id' => $id], 'Servicio extra registrado');
|
||||
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/send_formulario.php
|
||||
* Crea un envío (instancia) con datos pre-llenados y devuelve el link para WhatsApp.
|
||||
* Accesible por admin Y enfermero.
|
||||
*
|
||||
* Body JSON:
|
||||
* { formulario_id, paciente_id?, domicilio_id?, datos_prefilled:{...}, enviado_via? }
|
||||
*
|
||||
* Returns:
|
||||
* { id, token, url, whatsapp_url, mensaje_wa }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
|
||||
if (empty($datos['formulario_id'])) jsonError('formulario_id es obligatorio');
|
||||
|
||||
// Verificar que la plantilla existe
|
||||
$form = new Formulario();
|
||||
$plantilla = $form->obtener((int)$datos['formulario_id']);
|
||||
if (!$plantilla) jsonError('Formulario no encontrado', 404);
|
||||
|
||||
// Pre-llenado: si viene paciente_id, auto-enriquecer con datos del paciente
|
||||
$prefilled = $datos['datos_prefilled'] ?? [];
|
||||
if (!empty($datos['paciente_id'])) {
|
||||
$pac = (new Paciente())->obtener((int)$datos['paciente_id']);
|
||||
if ($pac) {
|
||||
// Mapeo automático de campos estándar
|
||||
$prefilled['__paciente'] = [
|
||||
'id' => $pac['id'],
|
||||
'nombre_completo' => $pac['nombre_completo'],
|
||||
'numero_documento'=> $pac['numero_documento'],
|
||||
'tipo_documento' => $pac['tipo_documento'],
|
||||
'telefono' => $pac['telefono'],
|
||||
'email' => $pac['email'] ?? '',
|
||||
'fecha_nacimiento'=> $pac['fecha_nacimiento'] ?? '',
|
||||
'eps' => $pac['eps'] ?? '',
|
||||
'direccion' => $pac['direccion'] ?? '',
|
||||
'ciudad' => $pac['ciudad'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$datos['datos_prefilled'] = json_encode($prefilled, JSON_UNESCAPED_UNICODE);
|
||||
$datos['enviado_via'] = $datos['enviado_via'] ?? 'whatsapp';
|
||||
|
||||
$envioId = $form->crearEnvio($datos, adminId());
|
||||
|
||||
// Construir URL pública
|
||||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$base = $protocol . '://' . $host;
|
||||
// Subir un nivel desde /api/lab/
|
||||
$basePath = rtrim(dirname(dirname(dirname($_SERVER['SCRIPT_NAME']))), '/');
|
||||
|
||||
// Obtener el token del envío recién creado
|
||||
$db = Database::getInstance();
|
||||
$envio = $db->fetch('SELECT token FROM lab_form_envios WHERE id = ?', [$envioId]);
|
||||
$token = $envio['token'];
|
||||
$url = $base . $basePath . '/form_cliente.php?t=' . $token;
|
||||
|
||||
// Mensaje de WhatsApp preformateado
|
||||
$nomPac = $prefilled['__paciente']['nombre_completo'] ?? 'Estimado paciente';
|
||||
$nomForm = $plantilla['nombre'];
|
||||
$mensajeWa = "Hola $nomPac, le enviamos el formulario *\"$nomForm\"* para que lo complete y firme digitalmente.\n\n"
|
||||
. "👉 Accede aquí:\n$url\n\n"
|
||||
. "El enlace expira en 7 días. Si tiene dudas contáctenos.";
|
||||
|
||||
$telPac = $prefilled['__paciente']['telefono'] ?? '';
|
||||
$waUrl = 'https://wa.me/' . preg_replace('/\D/', '', $telPac)
|
||||
. '?text=' . rawurlencode($mensajeWa);
|
||||
|
||||
jsonOk([
|
||||
'id' => $envioId,
|
||||
'token' => $token,
|
||||
'url' => $url,
|
||||
'whatsapp_url'=> $waUrl,
|
||||
'mensaje_wa' => $mensajeWa,
|
||||
], 'Envío creado correctamente');
|
||||
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/submit_formulario.php — Guardar respuesta del cliente (público, sin auth)
|
||||
* GET /api/lab/submit_formulario.php?t=TOKEN — Verificar estado del token (público)
|
||||
*
|
||||
* Body JSON (POST): { token, datos_cliente:{...}, firma_svg? }
|
||||
*/
|
||||
// No usar _helpers.php porque este endpoint es PÚBLICO (no requiere sesión)
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../classes/lab/Formulario.php';
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
|
||||
|
||||
function pubOk(array $data = [], string $msg = ''): void {
|
||||
$r = ['success' => true];
|
||||
if ($msg) $r['message'] = $msg;
|
||||
echo json_encode(array_merge($r, $data), JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
function pubErr(string $msg, int $code = 400): void {
|
||||
http_response_code($code);
|
||||
echo json_encode(['success' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$form = new Formulario();
|
||||
|
||||
// GET: cargar datos del formulario por token
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$token = $_GET['t'] ?? '';
|
||||
if (!$token) pubErr('Token requerido');
|
||||
$envio = $form->obtenerPorToken($token);
|
||||
if (!$envio) pubErr('Link inválido o expirado', 404);
|
||||
|
||||
// Config de diseño: primero override del formulario, luego config global
|
||||
$db2 = Database::getInstance();
|
||||
$cfgRows = $db2->fetchAll('SELECT clave, valor FROM lab_config ORDER BY clave');
|
||||
$gCfg = [];
|
||||
foreach ($cfgRows as $r) { $gCfg[$r['clave']] = $r['valor']; }
|
||||
$docConfig = [
|
||||
'doc_color' => $envio['doc_color'] ?: ($gCfg['doc_color'] ?? '#1565c0'),
|
||||
'doc_logo_base64' => $envio['doc_logo_base64'] ?: ($gCfg['doc_logo_base64'] ?? null),
|
||||
'doc_encabezado' => $envio['doc_encabezado'] ?: ($gCfg['empresa_nombre'] ?? null),
|
||||
'doc_subtitulo' => $envio['doc_subtitulo'] ?: ($gCfg['empresa_subtitulo'] ?? null),
|
||||
'doc_pie_pagina' => $envio['doc_pie_pagina'] ?: ($gCfg['doc_pie_pagina'] ?? null),
|
||||
];
|
||||
|
||||
// Estructura que espera form_cliente.php
|
||||
pubOk([
|
||||
'formulario' => [
|
||||
'nombre' => $envio['form_nombre'],
|
||||
'descripcion' => $envio['form_descripcion'] ?? '',
|
||||
'esquema_decoded'=> $envio['esquema_decoded'],
|
||||
'permite_firma' => (bool)($envio['permite_firma'] ?? false),
|
||||
'requiere_firma' => (bool)($envio['requiere_firma'] ?? false),
|
||||
],
|
||||
'envio' => [
|
||||
'id' => $envio['id'],
|
||||
'estado' => $envio['estado'],
|
||||
'firma_svg' => $envio['firma_svg'] ?? null,
|
||||
'hash_verificacion'=> $envio['hash_verificacion'] ?? null,
|
||||
'completado_en' => $envio['completado_en'] ?? null,
|
||||
],
|
||||
'prefilled' => $envio['prefilled_decoded'] ?? [],
|
||||
'config' => $docConfig,
|
||||
]);
|
||||
}
|
||||
|
||||
// POST: guardar respuesta
|
||||
$raw = file_get_contents('php://input');
|
||||
$datos = json_decode($raw, true) ?? [];
|
||||
|
||||
$token = $datos['token'] ?? '';
|
||||
if (!$token) pubErr('Token requerido');
|
||||
|
||||
$envio = $form->obtenerPorToken($token);
|
||||
if (!$envio) pubErr('Link inválido o expirado', 404);
|
||||
|
||||
if (in_array($envio['estado'], ['completado', 'firmado'])) {
|
||||
// El formulario ya fue guardado (posible reintento tras error de red).
|
||||
// Devolvemos éxito con los datos existentes para que el cliente no quede bloqueado.
|
||||
$db = Database::getInstance();
|
||||
$saved = $db->fetch('SELECT id, hash_verificacion, firma_svg FROM lab_form_envios WHERE token = ?', [$token]);
|
||||
pubOk([
|
||||
'firmado' => !empty($saved['firma_svg']),
|
||||
'hash' => $saved['hash_verificacion'] ?? null,
|
||||
'envio_id' => $saved['id'] ?? null,
|
||||
'ya_enviado'=> true,
|
||||
], 'Formulario enviado correctamente. ¡Gracias!');
|
||||
}
|
||||
|
||||
$datosCliente = $datos['datos_cliente'] ?? [];
|
||||
$firmaSvg = $datos['firma_svg'] ?? null;
|
||||
$firmaFoto = $datos['firma_foto'] ?? null;
|
||||
|
||||
// Guardar foto de firma dentro de datos_cliente para no requerir nueva columna
|
||||
if ($firmaFoto) {
|
||||
$datosCliente['__firma_foto'] = $firmaFoto;
|
||||
}
|
||||
|
||||
// Validar que si requiere firma, venga
|
||||
if ($envio['requiere_firma'] && !$firmaSvg) {
|
||||
pubErr('La firma es obligatoria para este formulario');
|
||||
}
|
||||
|
||||
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
|
||||
$ok = $form->guardarRespuesta($token, $datosCliente, $firmaSvg, $ip, $ua);
|
||||
if (!$ok) pubErr('No se pudo guardar la respuesta', 500);
|
||||
|
||||
// Recuperar hash e id para la respuesta
|
||||
$db = Database::getInstance();
|
||||
$saved = $db->fetch('SELECT id, hash_verificacion FROM lab_form_envios WHERE token = ?', [$token]);
|
||||
|
||||
pubOk([
|
||||
'firmado' => (bool)$firmaSvg,
|
||||
'hash' => $saved['hash_verificacion'] ?? null,
|
||||
'envio_id' => $saved['id'] ?? null,
|
||||
], 'Formulario enviado correctamente. ¡Gracias!');
|
||||
|
||||
} catch (Exception $e) {
|
||||
pubErr($e->getMessage(), 500);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/update_domicilio_enfermero.php
|
||||
* El enfermero actualiza el estado de un domicilio asignado.
|
||||
*
|
||||
* Body JSON:
|
||||
* { domicilio_id, nuevo_estado, notas? }
|
||||
*
|
||||
* Transiciones permitidas al enfermero:
|
||||
* programado → confirmado
|
||||
* confirmado → en_camino
|
||||
* en_camino → en_domicilio
|
||||
* en_domicilio → completado
|
||||
* cualquiera → cancelado (con notas obligatorias)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('POST');
|
||||
|
||||
// Flujo permitido enfermero: estado_actual => [estados_siguientes_posibles]
|
||||
const TRANSICIONES_ENFERMERO = [
|
||||
'programado' => ['confirmado'],
|
||||
'confirmado' => ['en_camino', 'cancelado'],
|
||||
'en_camino' => ['en_domicilio', 'cancelado'],
|
||||
'en_domicilio' => ['completado', 'cancelado'],
|
||||
];
|
||||
|
||||
try {
|
||||
$datos = inputJson();
|
||||
$domId = (int)($datos['domicilio_id'] ?? 0);
|
||||
$estado = trim($datos['nuevo_estado'] ?? '');
|
||||
$notas = trim($datos['notas'] ?? '');
|
||||
|
||||
if (!$domId) jsonError('domicilio_id requerido');
|
||||
if (!$estado) jsonError('nuevo_estado requerido');
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Verificar domicilio y obtener estado actual
|
||||
$dom = $db->fetch(
|
||||
'SELECT id, estado, paciente_id FROM lab_domicilios WHERE id = ?',
|
||||
[$domId]
|
||||
);
|
||||
if (!$dom) jsonError('Domicilio no encontrado', 404);
|
||||
|
||||
// Verificar que el enfermero tenga asignación activa
|
||||
if (userRole() === 'enfermero') {
|
||||
$eid = enfermeraId();
|
||||
$asig = $db->fetch(
|
||||
"SELECT a.id FROM lab_asignaciones a
|
||||
WHERE a.domicilio_id = ? AND a.enfermera_id = ?
|
||||
AND a.estado NOT IN ('liberada','completada')",
|
||||
[$domId, $eid]
|
||||
);
|
||||
if (!$asig) jsonError('No tienes asignación activa para este domicilio', 403);
|
||||
|
||||
// Validar transición
|
||||
$estadoActual = $dom['estado'];
|
||||
$permitidos = TRANSICIONES_ENFERMERO[$estadoActual] ?? [];
|
||||
if (!in_array($estado, $permitidos)) {
|
||||
jsonError("No puedes pasar de '$estadoActual' a '$estado'");
|
||||
}
|
||||
}
|
||||
|
||||
if ($estado === 'cancelado' && !$notas) {
|
||||
jsonError('Las notas son obligatorias al cancelar');
|
||||
}
|
||||
|
||||
// Actualizar domicilio
|
||||
$campos = ['estado' => $estado];
|
||||
if ($notas) $campos['notas_admin'] = $notas;
|
||||
if ($estado === 'completado') {
|
||||
$campos['hora_salida'] = date('H:i:s');
|
||||
}
|
||||
if ($estado === 'en_domicilio') {
|
||||
$campos['hora_llegada'] = date('H:i:s');
|
||||
}
|
||||
$db->update('lab_domicilios', $campos, 'id = ?', [$domId]);
|
||||
|
||||
// Actualizar asignación si completado/cancelado
|
||||
if (in_array($estado, ['completado', 'cancelado']) && userRole() === 'enfermero') {
|
||||
$estAsig = $estado === 'completado' ? 'completada' : 'liberada';
|
||||
$db->update(
|
||||
'lab_asignaciones',
|
||||
['estado' => $estAsig],
|
||||
'domicilio_id = ? AND enfermera_id = ?',
|
||||
[$domId, enfermeraId()]
|
||||
);
|
||||
}
|
||||
|
||||
jsonOk(['domicilio_id' => $domId, 'estado' => $estado], 'Estado actualizado correctamente');
|
||||
|
||||
} catch (Exception $e) {
|
||||
jsonError($e->getMessage(), 500);
|
||||
}
|
||||
+15
-10
@@ -15,18 +15,23 @@ header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Obtener todos los usuarios administradores
|
||||
// Obtener todos los usuarios administradores con info de rol
|
||||
$users = $db->fetchAll("
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
full_name,
|
||||
email,
|
||||
is_active,
|
||||
last_login,
|
||||
created_at
|
||||
FROM admin_users
|
||||
ORDER BY created_at DESC
|
||||
u.id,
|
||||
u.username,
|
||||
u.full_name,
|
||||
u.email,
|
||||
u.is_active,
|
||||
u.role,
|
||||
u.role_id,
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
r.name AS role_name,
|
||||
r.color AS role_color
|
||||
FROM admin_users u
|
||||
LEFT JOIN roles r ON r.id = u.role_id
|
||||
ORDER BY u.created_at DESC
|
||||
");
|
||||
|
||||
echo json_encode([
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
/**
|
||||
* API - Subir documento PDF de Términos y Condiciones
|
||||
*/
|
||||
|
||||
require_once '../config/config.php';
|
||||
|
||||
error_reporting(E_ALL);
|
||||
@ini_set('display_errors', 0);
|
||||
@ini_set('log_errors', 1);
|
||||
|
||||
requireAuthentication();
|
||||
|
||||
if (ob_get_level() === 0) ob_start();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: POST');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
|
||||
// ── Guardar/actualizar configuración de texto ──────────────────────────
|
||||
// Si se envía sin archivo (solo textos), actualizar la versión activa.
|
||||
$msgAceptacion = trim($_POST['mensaje_aceptacion'] ?? '');
|
||||
$msgRechazo = trim($_POST['mensaje_rechazo'] ?? '');
|
||||
$version = trim($_POST['version'] ?? '');
|
||||
$forzarReenvio = !empty($_POST['forzar_reenvio']) ? 1 : 0;
|
||||
|
||||
// ── Determinar si hay un archivo ───────────────────────────────────────
|
||||
$hasFile = isset($_FILES['pdf']) && $_FILES['pdf']['error'] === UPLOAD_ERR_OK;
|
||||
|
||||
if ($hasFile) {
|
||||
$file = $_FILES['pdf'];
|
||||
$tmpPath = $file['tmp_name'];
|
||||
$mime = mime_content_type($tmpPath);
|
||||
|
||||
// Validar que sea PDF
|
||||
if ($mime !== 'application/pdf') {
|
||||
if (ob_get_length()) ob_clean();
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Solo se permite subir archivos PDF']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Tamaño máximo 20 MB
|
||||
if ($file['size'] > 20 * 1024 * 1024) {
|
||||
if (ob_get_length()) ob_clean();
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'El archivo no puede superar 20 MB']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Crear directorio de destino si no existe
|
||||
$uploadDir = __DIR__ . '/../uploads/terms/';
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
// Nombre único para evitar sobreescribir
|
||||
$safeName = 'terminos_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.pdf';
|
||||
$destPath = $uploadDir . $safeName;
|
||||
|
||||
if (!move_uploaded_file($tmpPath, $destPath)) {
|
||||
throw new Exception('Error al guardar el archivo en el servidor');
|
||||
}
|
||||
|
||||
// URL pública
|
||||
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
|
||||
. '://' . $_SERVER['HTTP_HOST'];
|
||||
// Calcular ruta relativa desde la raíz del proyecto
|
||||
$docRoot = rtrim($_SERVER['DOCUMENT_ROOT'], '/');
|
||||
$absUpload = realpath($destPath);
|
||||
$relPath = '/' . ltrim(str_replace($docRoot, '', $absUpload), '/');
|
||||
$docUrl = $baseUrl . $relPath;
|
||||
}
|
||||
|
||||
// ── Desactivar versiones anteriores si se crea una nueva ──────────────
|
||||
if ($hasFile || !empty($version)) {
|
||||
// Si hay archivo o versión nueva, desactivar la anterior y crear registro nuevo
|
||||
$db->query("UPDATE terms_versions SET activa = 0 WHERE activa = 1");
|
||||
|
||||
$insertData = [
|
||||
'version' => $version ?: date('Y-m'),
|
||||
'mensaje_aceptacion' => $msgAceptacion,
|
||||
'mensaje_rechazo' => $msgRechazo,
|
||||
'forzar_reenvio' => $forzarReenvio,
|
||||
'activa' => 1,
|
||||
];
|
||||
|
||||
if ($hasFile) {
|
||||
$insertData['documento_url'] = $docUrl ?? null;
|
||||
$insertData['documento_nombre'] = $file['name'];
|
||||
}
|
||||
|
||||
$newId = $db->insert('terms_versions', $insertData);
|
||||
|
||||
// Si forzar_reenvio=1, resetear terms_pending en todos los usuarios para forzar re-lectura
|
||||
if ($forzarReenvio) {
|
||||
$db->query(
|
||||
"UPDATE users SET terms_pending = 0, terms_accepted_at = NULL, terms_version_id = NULL"
|
||||
);
|
||||
}
|
||||
|
||||
if (ob_get_length()) ob_clean();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'version_id' => $newId,
|
||||
'documento_url' => $insertData['documento_url'] ?? null,
|
||||
'message' => 'Términos guardados correctamente',
|
||||
]);
|
||||
} else {
|
||||
// Sin archivo ni versión → solo actualizar textos de la versión activa
|
||||
$active = $db->fetch("SELECT id FROM terms_versions WHERE activa = 1 ORDER BY id DESC LIMIT 1");
|
||||
if ($active) {
|
||||
$updateData = [];
|
||||
if ($msgAceptacion !== '') $updateData['mensaje_aceptacion'] = $msgAceptacion;
|
||||
if ($msgRechazo !== '') $updateData['mensaje_rechazo'] = $msgRechazo;
|
||||
$updateData['forzar_reenvio'] = $forzarReenvio;
|
||||
$db->update('terms_versions', $updateData, 'id = :id', ['id' => $active['id']]);
|
||||
|
||||
if ($forzarReenvio) {
|
||||
$db->query("UPDATE users SET terms_pending = 0, terms_accepted_at = NULL, terms_version_id = NULL");
|
||||
}
|
||||
|
||||
if (ob_get_length()) ob_clean();
|
||||
echo json_encode(['success' => true, 'message' => 'Configuración de términos actualizada']);
|
||||
} else {
|
||||
if (ob_get_length()) ob_clean();
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'No hay versión activa. Sube un documento primero.']);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('upload_terms_document.php error: ' . $e->getMessage());
|
||||
if (ob_get_length()) ob_clean();
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -560,3 +560,341 @@
|
||||
[2026-02-21 08:43:43] Auto-download failed for media 1217156256734654: Graph API fetch failed:
|
||||
[2026-02-21 08:43:43] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-02-21 08:43:43] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-02-21 09:03:28] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-02-21 09:03:28] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-02-21 09:03:29] Auto-download failed for media 1217156256734654: Graph API fetch failed:
|
||||
[2026-02-21 09:03:29] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-02-21 09:03:29] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A5iussOysleNqD_H3h6YqZ_"}}
|
||||
[2026-02-21 09:03:29] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-02-21 09:03:29] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-02-21 09:03:29] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-02-21 09:03:29] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"Alzc9zFuoIholZAYNlHTYj1"}}
|
||||
[2026-02-21 09:03:29] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-02-21 09:03:29] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-02-21 11:19:20] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-02-21 11:19:20] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-02-21 11:19:20] Auto-download failed for media 1217156256734654: Graph API fetch failed:
|
||||
[2026-02-21 11:19:20] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-02-21 11:19:20] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AJqoxZ4zAsaS-mNb208AiEo"}}
|
||||
[2026-02-21 11:19:20] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-02-21 11:19:21] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-02-21 11:19:26] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-02-21 11:19:26] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-02-21 11:19:26] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AisVkuFtWeUX2QwmhG1lqMx"}}
|
||||
[2026-02-21 11:19:26] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-02-21 11:19:26] Auto-download failed for media 1217156256734654: Graph API fetch failed:
|
||||
[2026-02-21 11:19:26] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-02-21 11:19:27] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-02-21 11:27:39] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-02-21 11:27:39] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-02-21 11:27:39] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AMnrK4EUy2AxN_FcOyJd0Qy"}}
|
||||
[2026-02-21 11:27:39] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-02-21 11:27:39] Auto-download failed for media 1217156256734654: Graph API fetch failed:
|
||||
[2026-02-21 11:27:39] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-02-21 11:27:40] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-02-21 11:44:53] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-02-21 11:44:53] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-02-21 11:44:53] Auto-download failed for media 1217156256734654: Graph API fetch failed:
|
||||
[2026-02-21 11:44:53] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-02-21 11:44:53] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"ANVda7F7ksRwxiU6oSPaxpP"}}
|
||||
[2026-02-21 11:44:53] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-02-21 11:44:53] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-02-21 11:45:00] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-02-21 11:45:00] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-02-21 11:45:00] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"Az1YG8RtWuRKoygP0Oa9_W-"}}
|
||||
[2026-02-21 11:45:00] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-02-21 11:45:00] Auto-download failed for media 1217156256734654: Graph API fetch failed:
|
||||
[2026-02-21 11:45:00] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-02-21 11:45:00] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-02-21 11:45:12] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-02-21 11:45:12] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-02-21 11:45:13] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AoPzxrE55hgFxTob08WBJhL"}}
|
||||
[2026-02-21 11:45:13] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-02-21 11:45:13] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-02-21 11:45:13] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-02-21 11:45:13] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-02-21 11:45:17] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-02-21 11:45:17] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-02-21 11:45:17] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AbGjiOPlXxbWchiwJ6CyXqW"}}
|
||||
[2026-02-21 11:45:17] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-02-21 11:45:17] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-02-21 11:45:17] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-02-21 11:45:17] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-02 11:30:52] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-02 11:30:52] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-02 11:30:53] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AZGri-6Z0DMgmM1A1sR8Pe7"}}
|
||||
[2026-03-02 11:30:53] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-02 11:30:53] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-02 11:30:53] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-02 11:30:53] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-02 11:30:54] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-02 11:30:54] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AOXECMUo93esakpIwFUpqYB"}}
|
||||
[2026-03-02 11:30:54] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-02 11:30:55] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-02 11:44:04] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-02 11:44:04] Request: GET /api/version/media-url.php?id=1551128883003002 GET:{"id":"1551128883003002"} POST:[]
|
||||
[2026-03-02 11:44:04] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-02 11:44:04] Request: GET /api/version/media-url.php?id=893454973089562 GET:{"id":"893454973089562"} POST:[]
|
||||
[2026-03-02 11:44:04] Serving local file for media ID 1551128883003002: /var/www/html/uploads/media_6979a044c17079.66378227.ogg
|
||||
[2026-03-02 11:44:04] Serving local file for media ID 893454973089562: /var/www/html/uploads/media_6979a3c5b236e8.04274010.ogg
|
||||
[2026-03-02 11:44:05] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AWIFhWSCUfg32iSw3oHxfDI"}}
|
||||
[2026-03-02 11:44:05] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-02 11:44:05] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-02 11:44:05] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-02 11:44:05] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-02 11:44:05] Request: GET /api/version/media-url.php?id=759892943831328 GET:{"id":"759892943831328"} POST:[]
|
||||
[2026-03-02 11:44:05] Request: GET /api/version/media-url.php?id=1334972502008596 GET:{"id":"1334972502008596"} POST:[]
|
||||
[2026-03-02 11:44:05] Request: GET /api/version/media-url.php?id=1601261651067176 GET:{"id":"1601261651067176"} POST:[]
|
||||
[2026-03-02 11:44:06] Request: GET /api/version/media-url.php?id=1043532494648003 GET:{"id":"1043532494648003"} POST:[]
|
||||
[2026-03-02 11:44:06] Serving local file for media ID 759892943831328: /var/www/html/uploads/media_6979a485787858.65742730.ogg
|
||||
[2026-03-02 11:44:06] Serving local file for media ID 1334972502008596: /var/www/html/uploads/media_6979a5b4690f83.69035164.ogg
|
||||
[2026-03-02 11:44:06] Request: GET /api/version/media-url.php?id=2079449202816102 GET:{"id":"2079449202816102"} POST:[]
|
||||
[2026-03-02 11:44:06] Serving local file for media ID 1601261651067176: /var/www/html/uploads/media_6979a9a3d39a72.62917885.ogg
|
||||
[2026-03-02 11:44:06] Serving local file for media ID 2079449202816102: /var/www/html/uploads/media_6979b17f07a0f4.63241756.mp4
|
||||
[2026-03-02 11:44:07] Serving local file for media ID 1043532494648003: /var/www/html/uploads/media_6979abd12b8131.97353205.ogg
|
||||
[2026-03-02 11:44:07] Request: GET /api/version/media-url.php?id=1551128883003002 GET:{"id":"1551128883003002"} POST:[]
|
||||
[2026-03-02 11:44:07] Request: GET /api/version/media-url.php?id=893454973089562 GET:{"id":"893454973089562"} POST:[]
|
||||
[2026-03-02 11:44:07] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-02 11:44:07] Serving local file for media ID 1551128883003002: /var/www/html/uploads/media_6979a044c17079.66378227.ogg
|
||||
[2026-03-02 11:44:07] Serving local file for media ID 893454973089562: /var/www/html/uploads/media_6979a3c5b236e8.04274010.ogg
|
||||
[2026-03-02 11:44:08] Request: GET /api/version/media-url.php?id=1334972502008596 GET:{"id":"1334972502008596"} POST:[]
|
||||
[2026-03-02 11:44:08] Request: GET /api/version/media-url.php?id=759892943831328 GET:{"id":"759892943831328"} POST:[]
|
||||
[2026-03-02 11:44:08] Serving local file for media ID 1334972502008596: /var/www/html/uploads/media_6979a5b4690f83.69035164.ogg
|
||||
[2026-03-02 11:44:08] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AnUP3aPhugLtqpAIj5gV7hY"}}
|
||||
[2026-03-02 11:44:08] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-02 11:44:08] Serving local file for media ID 759892943831328: /var/www/html/uploads/media_6979a485787858.65742730.ogg
|
||||
[2026-03-02 11:44:08] Request: GET /api/version/media-url.php?id=1601261651067176 GET:{"id":"1601261651067176"} POST:[]
|
||||
[2026-03-02 11:44:08] Request: GET /api/version/media-url.php?id=1043532494648003 GET:{"id":"1043532494648003"} POST:[]
|
||||
[2026-03-02 11:44:08] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-02 11:44:09] Serving local file for media ID 1601261651067176: /var/www/html/uploads/media_6979a9a3d39a72.62917885.ogg
|
||||
[2026-03-02 11:44:09] Serving local file for media ID 1043532494648003: /var/www/html/uploads/media_6979abd12b8131.97353205.ogg
|
||||
[2026-03-02 11:44:10] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-02 11:44:10] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"ApY5M23GqHWpJWH8zonLpm4"}}
|
||||
[2026-03-02 11:44:10] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-02 11:44:10] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 15:33:11] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 15:33:11] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 15:33:12] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AcxD9u_TB-joWwYfreaHjC2"}}
|
||||
[2026-03-03 15:33:12] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 15:33:12] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 15:33:12] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 15:33:12] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 15:33:49] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 15:33:50] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 15:33:50] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AbtUJe0Nmk36wiWNfhue_h7"}}
|
||||
[2026-03-03 15:33:50] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 15:33:50] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 15:33:51] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 15:33:51] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 15:42:36] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 15:42:36] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 15:42:37] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"ATZVuoQTYbOHnlvCZpRsd-Y"}}
|
||||
[2026-03-03 15:42:37] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 15:42:37] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 15:42:37] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 15:42:37] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 15:42:42] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 15:42:42] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 15:42:42] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AOKgh1d-JiezKEWC9lJxPf8"}}
|
||||
[2026-03-03 15:42:42] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 15:42:43] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 15:42:43] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 15:42:43] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 16:14:55] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 16:14:55] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 16:14:56] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 16:14:56] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 16:14:56] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AktmTcgRtxp3ASb0WsQanLk"}}
|
||||
[2026-03-03 16:14:56] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 16:14:56] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 18:02:12] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 18:02:12] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 18:02:13] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 18:02:13] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 18:02:13] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AaqmOWStyN4bNAec5giL9Ym"}}
|
||||
[2026-03-03 18:02:13] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 18:02:13] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 18:12:46] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 18:12:46] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 18:12:47] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AS4OBeTbrmdjdjvcVm4XDym"}}
|
||||
[2026-03-03 18:12:47] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 18:12:47] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 18:12:47] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 18:12:47] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 18:18:54] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 18:18:55] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 18:18:56] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AVZ_WGk3WGsMwUTojFSvT3X"}}
|
||||
[2026-03-03 18:18:56] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 18:18:56] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 18:18:56] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 18:18:56] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 18:26:33] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 18:26:33] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 18:26:34] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A-rsOJy6Nt5bu8V2TiFfbGa"}}
|
||||
[2026-03-03 18:26:34] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 18:26:34] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 18:26:34] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 18:26:34] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 18:40:46] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 18:40:46] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 18:40:47] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AVgNR8KDAo3MJZd7sUEmwOA"}}
|
||||
[2026-03-03 18:40:47] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 18:40:47] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 18:40:47] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 18:40:47] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 18:45:56] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 18:45:57] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 18:45:58] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A2BA0V1lFNEweoVj828N08C"}}
|
||||
[2026-03-03 18:45:58] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 18:45:58] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 18:45:58] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 18:45:58] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 18:52:25] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 18:52:25] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 18:52:26] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A9NoZ-fzHlsYz9LVztCvsTs"}}
|
||||
[2026-03-03 18:52:26] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 18:52:26] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 18:52:26] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 18:52:26] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-03 18:53:23] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-03 18:53:24] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-03 18:53:24] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-03 18:53:24] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-03 18:53:24] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AFgjiwRrmVTXzoPHLwKsnKn"}}
|
||||
[2026-03-03 18:53:24] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-03 18:53:25] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-07 12:50:23] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-07 12:50:23] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-07 12:50:24] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AcQVcLKIJwSqIoyn5edcq6N"}}
|
||||
[2026-03-07 12:50:24] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-07 12:50:24] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-07 12:50:24] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-07 12:50:24] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-09 19:02:27] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-09 19:02:27] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-09 19:02:28] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AOwAqoVcoD5OhMDtVtKEmCr"}}
|
||||
[2026-03-09 19:02:28] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-09 19:02:28] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-09 19:02:28] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-09 19:02:28] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-09 19:02:34] Request: GET /api/version/media-url.php?id=876668735219378 GET:{"id":"876668735219378"} POST:[]
|
||||
[2026-03-09 19:02:35] Auto-download failed for media 876668735219378: Graph API fetch failed for media 876668735219378
|
||||
[2026-03-09 19:02:35] Graph API request to https://graph.facebook.com/v22.0/876668735219378
|
||||
[2026-03-09 19:03:43] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-09 19:03:43] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-09 19:03:44] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"ADXyHWK69LBJb0Xcb5WF6YN"}}
|
||||
[2026-03-09 19:03:44] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-09 19:03:44] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-09 19:03:44] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-09 19:03:44] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-09 19:44:40] Request: GET /api/version/media-url.php?id=876668735219378 GET:{"id":"876668735219378"} POST:[]
|
||||
[2026-03-09 19:44:41] Auto-download failed for media 876668735219378: Graph API fetch failed for media 876668735219378
|
||||
[2026-03-09 19:44:41] Graph API request to https://graph.facebook.com/v22.0/876668735219378
|
||||
[2026-03-10 11:48:52] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 11:48:52] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 11:48:53] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AmeTveBUDoB7IYGuJQpKn_S"}}
|
||||
[2026-03-10 11:48:53] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 11:48:53] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 11:48:53] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 11:48:53] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 11:49:09] Request: GET /api/version/media-url.php?id=876668735219378 GET:{"id":"876668735219378"} POST:[]
|
||||
[2026-03-10 11:49:10] Auto-download failed for media 876668735219378: Graph API fetch failed for media 876668735219378
|
||||
[2026-03-10 11:49:10] Graph API request to https://graph.facebook.com/v22.0/876668735219378
|
||||
[2026-03-10 11:49:20] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 11:49:20] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 11:49:21] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AqqWUUrnY6r8ZIl70J0e6Og"}}
|
||||
[2026-03-10 11:49:21] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 11:49:21] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 11:49:21] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 11:49:21] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 11:58:05] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 11:58:05] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 11:58:06] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AV59Bbr9DFXNl7y039WL8dV"}}
|
||||
[2026-03-10 11:58:06] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 11:58:06] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 11:58:06] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 11:58:06] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 12:19:04] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 12:19:04] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 12:19:06] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AGWqv0bu7HvWrrjbR65TJIf"}}
|
||||
[2026-03-10 12:19:06] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 12:19:06] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 12:19:06] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 12:19:06] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 12:19:13] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 12:19:13] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 12:19:14] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AgIetbmT5l7HlIzACky9rEV"}}
|
||||
[2026-03-10 12:19:14] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 12:19:14] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 12:19:14] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 12:19:14] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 12:19:37] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 12:19:37] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 12:19:38] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 12:19:38] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 12:19:38] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AThpsMjn6_1g_sqRZBJrtHu"}}
|
||||
[2026-03-10 12:19:38] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 12:19:38] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 12:28:02] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 12:28:02] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 12:28:03] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A1pX_IwbWh_KHKUuck-nTb7"}}
|
||||
[2026-03-10 12:28:03] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 12:28:04] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 12:28:04] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 12:28:04] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 13:22:57] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 13:22:57] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 13:22:57] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AwADPHo5iki123yn3HWL7YD"}}
|
||||
[2026-03-10 13:22:57] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 13:22:58] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 13:22:58] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 13:22:58] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 13:45:06] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 13:45:06] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 13:45:07] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A5rBKn9_qO3AUr1NYK4npdP"}}
|
||||
[2026-03-10 13:45:07] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 13:45:07] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 13:45:07] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 13:45:07] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 16:35:00] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 16:35:00] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 16:35:01] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AZil5Jg5IUvgKBvNXuqNAgJ"}}
|
||||
[2026-03-10 16:35:01] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 16:35:01] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 16:35:01] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 16:35:01] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 16:35:41] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 16:35:41] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 16:35:42] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AhZK_sM5jxmWJyHR0SDg-S_"}}
|
||||
[2026-03-10 16:35:42] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 16:35:42] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 16:35:42] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 16:35:42] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 16:59:13] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 16:59:13] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 16:59:14] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"A9BtUEbjlzsjlLVBf--RVAP"}}
|
||||
[2026-03-10 16:59:14] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 16:59:14] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 16:59:14] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 16:59:14] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 16:59:18] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 16:59:18] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 16:59:18] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AndqVm9VvEWqTibjCIvpkQK"}}
|
||||
[2026-03-10 16:59:18] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 16:59:18] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 16:59:18] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 16:59:18] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 16:59:29] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 16:59:29] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 16:59:29] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"Am3mQzxQnonDMVVhrF4yGVP"}}
|
||||
[2026-03-10 16:59:29] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 16:59:29] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 16:59:29] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 16:59:29] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
[2026-03-10 18:13:02] Request: GET /api/version/media-url.php?id=1217156256734654 GET:{"id":"1217156256734654"} POST:[]
|
||||
[2026-03-10 18:13:02] Request: GET /api/version/media-url.php?url=https%3A%2F%2Flookaside.fbsbx.com%2Fwhatsapp_business%2Fattachments%2F%3Fmid%3D2371342336638440%26source%3Dwebhook%26ext%3D1769575796%26hash%3DARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA GET:{"url":"https:\/\/lookaside.fbsbx.com\/whatsapp_business\/attachments\/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA"} POST:[]
|
||||
[2026-03-10 18:13:02] Graph API fetch failed for mid 2371342336638440 - http: 400 - err: - resp: {"error":{"message":"Unsupported get request. Object with ID '2371342336638440' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https:\/\/developers.facebook.com\/docs\/graph-api","type":"GraphMethodException","code":100,"error_subcode":33,"fbtrace_id":"AHjV--ez4QSdPn8y8ecMc3O"}}
|
||||
[2026-03-10 18:13:02] Streaming Facebook media with Authorization: https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA
|
||||
[2026-03-10 18:13:02] streamRemoteFile failed: url=https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=2371342336638440&source=webhook&ext=1769575796&hash=ARndVKdw1p0CCx3_DpTblR2aOVkb_W5mbTqGFPSOK77cjA err= http=404
|
||||
[2026-03-10 18:13:02] Auto-download failed for media 1217156256734654: Graph API fetch failed for media 1217156256734654
|
||||
[2026-03-10 18:13:02] Graph API request to https://graph.facebook.com/v22.0/1217156256734654
|
||||
|
||||
+159
-17
@@ -793,6 +793,9 @@ class SimpleWhatsAppManager {
|
||||
} catch (error) {
|
||||
this.showError('Error cargando configuraciones: ' + error.message);
|
||||
}
|
||||
|
||||
// Cargar siempre el estado del documento de términos
|
||||
if (typeof loadTermsDoc === 'function') loadTermsDoc();
|
||||
}
|
||||
|
||||
updatesystem_configForm(data) {
|
||||
@@ -805,7 +808,9 @@ class SimpleWhatsAppManager {
|
||||
{ id: 'webhook-token', value: data.webhook_verify_token },
|
||||
{ id: 'api-url', value: data.api_url },
|
||||
{ id: 'business-name', value: data.business_name },
|
||||
{ id: 'welcome-message', value: data.welcome_message }
|
||||
{ id: 'welcome-message', value: data.welcome_message },
|
||||
{ id: 'terms-message', value: data.terms_message },
|
||||
{ id: 'terms-rejected-message', value: data.terms_rejected_message }
|
||||
];
|
||||
|
||||
fields.forEach(field => {
|
||||
@@ -829,7 +834,9 @@ class SimpleWhatsAppManager {
|
||||
webhook_verify_token: document.getElementById('webhook-token').value,
|
||||
api_url: document.getElementById('api-url').value,
|
||||
business_name: document.getElementById('business-name').value,
|
||||
welcome_message: document.getElementById('welcome-message').value
|
||||
welcome_message: document.getElementById('welcome-message').value,
|
||||
terms_message: document.getElementById('terms-message')?.value || '',
|
||||
terms_rejected_message: document.getElementById('terms-rejected-message')?.value || ''
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1949,22 +1956,9 @@ class SimpleWhatsAppManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar usuarios administradores del sistema
|
||||
// Gestión de usuarios centralizada en lab_usuarios.php
|
||||
async loadAdminUsers() {
|
||||
this.log('Cargando usuarios administradores...');
|
||||
|
||||
try {
|
||||
const response = await this.apiCall('list_admin_users.php');
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
renderAdminUsersTable(response.data);
|
||||
} else {
|
||||
this.showError('Error cargando usuarios administradores');
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('Error cargando usuarios administradores: ' + error.message, 'error');
|
||||
this.showError('Error cargando usuarios: ' + error.message);
|
||||
}
|
||||
// No-op: la UI redirige a lab_usuarios.php
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3436,6 +3430,141 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Gestión del documento PDF de Términos y Condiciones ─────────────────────
|
||||
(function () {
|
||||
function showTermsStatus(msg, type) {
|
||||
const el = document.getElementById('terms-upload-status');
|
||||
if (!el) return;
|
||||
el.style.display = 'block';
|
||||
el.className = 'mt-2 small text-' + (type || 'secondary');
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
function _fallbackCopy(text) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
const ok = document.execCommand('copy');
|
||||
showTermsStatus(ok ? '✔ URL copiada al portapapeles.' : '✖ No se pudo copiar.', ok ? 'success' : 'danger');
|
||||
} catch (e) {
|
||||
showTermsStatus('✖ No se pudo copiar: ' + text, 'danger');
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
async function loadTermsDoc() {
|
||||
try {
|
||||
const res = await fetch('api/get_terms_config.php');
|
||||
const json = await res.json();
|
||||
if (!json.success || !json.data) return;
|
||||
|
||||
const d = json.data;
|
||||
const nameEl = document.getElementById('terms-doc-name');
|
||||
const verEl = document.getElementById('terms-doc-version');
|
||||
const linkEl = document.getElementById('terms-doc-link');
|
||||
const copyBtn = document.getElementById('terms-doc-copy-btn');
|
||||
const currentEl = document.getElementById('terms-doc-current');
|
||||
const msgAcEl = document.getElementById('terms-message');
|
||||
const msgRjEl = document.getElementById('terms-rejected-message');
|
||||
|
||||
if (nameEl) nameEl.textContent = d.documento_nombre || '(sin nombre)';
|
||||
if (verEl) verEl.textContent = d.version || '—';
|
||||
if (linkEl) linkEl.href = d.documento_url || '#';
|
||||
if (currentEl) currentEl.style.display = d.documento_url ? '' : 'none';
|
||||
|
||||
// Rellenar textos si están en terms_versions (no sobreescribir si ya se cargaron)
|
||||
if (msgAcEl && !msgAcEl.value && d.mensaje_aceptacion) {
|
||||
msgAcEl.value = d.mensaje_aceptacion;
|
||||
}
|
||||
if (msgRjEl && !msgRjEl.value && d.mensaje_rechazo) {
|
||||
msgRjEl.value = d.mensaje_rechazo;
|
||||
}
|
||||
|
||||
if (copyBtn && d.documento_url) {
|
||||
copyBtn.onclick = () => {
|
||||
const url = d.documento_url;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(url)
|
||||
.then(() => showTermsStatus('✔ URL copiada al portapapeles.', 'success'))
|
||||
.catch(() => _fallbackCopy(url));
|
||||
} else {
|
||||
_fallbackCopy(url);
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('loadTermsDoc error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadTermsDocument() {
|
||||
const fileInput = document.getElementById('terms-pdf-file');
|
||||
const versionEl = document.getElementById('terms-new-version');
|
||||
const forceEl = document.getElementById('terms-force-reaccept');
|
||||
const msgAcEl = document.getElementById('terms-message');
|
||||
const msgRjEl = document.getElementById('terms-rejected-message');
|
||||
|
||||
const formData = new FormData();
|
||||
if (fileInput && fileInput.files.length > 0) {
|
||||
formData.append('pdf', fileInput.files[0]);
|
||||
}
|
||||
if (versionEl) formData.append('version', versionEl.value.trim());
|
||||
if (forceEl) formData.append('forzar_reenvio', forceEl.checked ? '1' : '0');
|
||||
if (msgAcEl) formData.append('mensaje_aceptacion', msgAcEl.value);
|
||||
if (msgRjEl) formData.append('mensaje_rechazo', msgRjEl.value);
|
||||
|
||||
showTermsStatus('Guardando…', 'secondary');
|
||||
|
||||
try {
|
||||
const res = await fetch('api/upload_terms_document.php', { method: 'POST', body: formData });
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
showTermsStatus('✔ ' + (json.message || 'Guardado correctamente.'), 'success');
|
||||
if (json.documento_url) {
|
||||
const linkEl = document.getElementById('terms-doc-link');
|
||||
if (linkEl) linkEl.href = json.documento_url;
|
||||
document.getElementById('terms-doc-current').style.display = '';
|
||||
const nameEl = document.getElementById('terms-doc-name');
|
||||
if (nameEl && fileInput && fileInput.files[0]) nameEl.textContent = fileInput.files[0].name;
|
||||
const verEl = document.getElementById('terms-doc-version');
|
||||
if (verEl && versionEl) verEl.textContent = versionEl.value || '—';
|
||||
}
|
||||
// Limpiar inputs de archivo
|
||||
if (fileInput) fileInput.value = '';
|
||||
if (forceEl) forceEl.checked = false;
|
||||
} else {
|
||||
showTermsStatus('✖ ' + (json.error || 'Error al guardar.'), 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showTermsStatus('✖ Error de red: ' + e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const uploadBtn = document.getElementById('terms-upload-btn');
|
||||
if (uploadBtn) uploadBtn.addEventListener('click', uploadTermsDocument);
|
||||
|
||||
// Cargar estado del documento cuando se activa el tab de configuración
|
||||
const cfgTab = document.querySelector('[data-bs-target="#system-config"], [href="#system-config"]');
|
||||
if (cfgTab) {
|
||||
cfgTab.addEventListener('shown.bs.tab', loadTermsDoc);
|
||||
}
|
||||
// Si el tab ya está activo al cargar la página
|
||||
const cfgPanel = document.getElementById('system-config');
|
||||
if (cfgPanel && cfgPanel.classList.contains('active')) {
|
||||
loadTermsDoc();
|
||||
}
|
||||
});
|
||||
|
||||
// Exponer para que loadsystem_config() pueda llamarla
|
||||
window.loadTermsDoc = loadTermsDoc;
|
||||
})();
|
||||
// ─── / Términos ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Funciones globales para gestión de usuarios
|
||||
|
||||
// Función para editar usuario
|
||||
@@ -4526,6 +4655,19 @@ window.refreshData = function() {
|
||||
};
|
||||
|
||||
// Función para exportar usuarios
|
||||
window.exportConversations = function(filters) {
|
||||
const params = new URLSearchParams(filters || {});
|
||||
const url = (window.whatsappManager ? window.whatsappManager.apiBaseUrl : 'api/') + 'export_conversations.php';
|
||||
const full = params.toString() ? url + '?' + params.toString() : url;
|
||||
const a = document.createElement('a');
|
||||
a.href = full;
|
||||
a.download = '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
if (window.whatsappManager) window.whatsappManager.showSuccess('Descarga de conversaciones iniciada');
|
||||
};
|
||||
|
||||
window.exportUsers = function() {
|
||||
console.log('Exportando usuarios...');
|
||||
|
||||
|
||||
@@ -36,6 +36,12 @@ if (!isUserLoggedIn()) {
|
||||
exit('Acceso denegado. <a href="login.php">Iniciar sesión</a>');
|
||||
}
|
||||
|
||||
// Enfermeros solo pueden ver su portal, no el panel admin
|
||||
if (isEnfermero()) {
|
||||
header('Location: enfermero_portal.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Verificar timeout de sesión (ya se hace en config.php, pero por seguridad)
|
||||
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > SESSION_TIMEOUT)) {
|
||||
session_destroy();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
/**
|
||||
* ActividadAdmin — Trazabilidad del módulo administrativo de laboratorio
|
||||
* Registra toda acción realizada sobre las entidades del módulo.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
|
||||
class ActividadAdmin {
|
||||
|
||||
private Database $db;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Registro de actividad
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registra una acción en el log.
|
||||
*
|
||||
* @param int|null $adminId admin_users.id
|
||||
* @param string $modulo pacientes|ordenes|domicilios|enfermeras|asignaciones
|
||||
* @param string $accion crear|editar|eliminar|autorizar|rechazar|asignar...
|
||||
* @param int|null $entidadId ID del registro afectado
|
||||
* @param mixed $detalle Texto o array (se serializa como JSON)
|
||||
*/
|
||||
public function registrar(
|
||||
?int $adminId,
|
||||
string $modulo,
|
||||
string $accion,
|
||||
?int $entidadId = null,
|
||||
$detalle = null
|
||||
): int {
|
||||
$adminNombre = null;
|
||||
|
||||
if ($adminId) {
|
||||
$row = $this->db->fetch(
|
||||
'SELECT full_name FROM admin_users WHERE id = ?',
|
||||
[$adminId]
|
||||
);
|
||||
$adminNombre = $row['full_name'] ?? null;
|
||||
}
|
||||
|
||||
$detalleStr = is_array($detalle) || is_object($detalle)
|
||||
? json_encode($detalle, JSON_UNESCAPED_UNICODE)
|
||||
: (string)($detalle ?? '');
|
||||
|
||||
return $this->db->insert('lab_actividad_admin', [
|
||||
'admin_id' => $adminId,
|
||||
'admin_nombre'=> $adminNombre,
|
||||
'modulo' => $modulo,
|
||||
'accion' => $accion,
|
||||
'entidad_id' => $entidadId,
|
||||
'detalle' => $detalleStr,
|
||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Consultas
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Actividad reciente del módulo.
|
||||
*
|
||||
* @param int $limit
|
||||
* @param string $modulo Filtro opcional por módulo
|
||||
*/
|
||||
public function reciente(int $limit = 50, string $modulo = ''): array {
|
||||
$where = $modulo ? 'WHERE a.modulo = ?' : '';
|
||||
$params = $modulo ? [$modulo] : [];
|
||||
$params[] = $limit;
|
||||
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
a.*,
|
||||
u.username AS admin_username
|
||||
FROM lab_actividad_admin a
|
||||
LEFT JOIN admin_users u ON u.id = a.admin_id
|
||||
$where
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ?
|
||||
", $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actividad de un administrador específico.
|
||||
*/
|
||||
public function porAdmin(int $adminId, int $limit = 100): array {
|
||||
return $this->db->fetchAll("
|
||||
SELECT * FROM lab_actividad_admin
|
||||
WHERE admin_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
", [$adminId, $limit]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actividad sobre una entidad concreta.
|
||||
*/
|
||||
public function porEntidad(string $modulo, int $entidadId): array {
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
a.*,
|
||||
u.username AS admin_username
|
||||
FROM lab_actividad_admin a
|
||||
LEFT JOIN admin_users u ON u.id = a.admin_id
|
||||
WHERE a.modulo = ? AND a.entidad_id = ?
|
||||
ORDER BY a.created_at ASC
|
||||
", [$modulo, $entidadId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estadísticas de actividad por módulo en un rango de fechas.
|
||||
*/
|
||||
public function estadisticas(string $desde, string $hasta): array {
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
modulo,
|
||||
accion,
|
||||
COUNT(*) AS total
|
||||
FROM lab_actividad_admin
|
||||
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||
GROUP BY modulo, accion
|
||||
ORDER BY modulo, total DESC
|
||||
", [$desde, $hasta]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* Asignacion — Asignación de enfermera a un domicilio.
|
||||
* Solo puede haber una asignación activa por domicilio (UNIQUE KEY).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
class Asignacion {
|
||||
|
||||
private Database $db;
|
||||
private ActividadAdmin $log;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
$this->log = new ActividadAdmin();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CRUD
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Obtener asignación por ID.
|
||||
*/
|
||||
public function obtener(int $id): ?array {
|
||||
return $this->db->fetch("
|
||||
SELECT
|
||||
a.*,
|
||||
e.nombre_completo AS enfermera_nombre,
|
||||
e.telefono AS enfermera_telefono,
|
||||
d.fecha_programada,
|
||||
d.hora_programada,
|
||||
d.estado AS domicilio_estado,
|
||||
d.direccion,
|
||||
p.nombre_completo AS paciente_nombre
|
||||
FROM lab_asignaciones a
|
||||
JOIN lab_enfermeras e ON e.id = a.enfermera_id
|
||||
JOIN lab_domicilios d ON d.id = a.domicilio_id
|
||||
JOIN lab_pacientes p ON p.id = d.paciente_id
|
||||
WHERE a.id = ?
|
||||
", [$id]) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asignar (o reasignar) enfermera a un domicilio.
|
||||
* - Si ya existía asignación la reemplaza.
|
||||
*/
|
||||
public function asignar(
|
||||
int $domicilioId,
|
||||
int $enfermeraId,
|
||||
?int $adminId = null,
|
||||
string $notas = ''
|
||||
): int {
|
||||
// ¿Ya existe?
|
||||
$existente = $this->db->fetch(
|
||||
'SELECT id FROM lab_asignaciones WHERE domicilio_id = ?',
|
||||
[$domicilioId]
|
||||
);
|
||||
|
||||
if ($existente) {
|
||||
$this->db->update('lab_asignaciones', [
|
||||
'enfermera_id' => $enfermeraId,
|
||||
'asignada_por' => $adminId,
|
||||
'estado' => 'asignada',
|
||||
'notas' => $notas,
|
||||
], 'id = ?', [$existente['id']]);
|
||||
|
||||
$id = $existente['id'];
|
||||
$accion = 'reasignar';
|
||||
} else {
|
||||
$id = $this->db->insert('lab_asignaciones', [
|
||||
'domicilio_id' => $domicilioId,
|
||||
'enfermera_id' => $enfermeraId,
|
||||
'asignada_por' => $adminId,
|
||||
'estado' => 'asignada',
|
||||
'notas' => $notas,
|
||||
]);
|
||||
$accion = 'asignar';
|
||||
}
|
||||
|
||||
$this->log->registrar($adminId, 'asignaciones', $accion, $id, [
|
||||
'domicilio_id' => $domicilioId,
|
||||
'enfermera_id' => $enfermeraId,
|
||||
]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liberar asignación (sin eliminar, cambia estado a 'liberada').
|
||||
*/
|
||||
public function liberar(int $id, ?int $adminId = null, string $motivo = ''): bool {
|
||||
$ok = $this->db->update('lab_asignaciones', ['estado' => 'liberada'], 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'asignaciones', 'liberar', $id, ['motivo' => $motivo]);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar asignación como completada.
|
||||
*/
|
||||
public function completar(int $id, ?int $adminId = null): bool {
|
||||
$ok = $this->db->update('lab_asignaciones', ['estado' => 'completada'], 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'asignaciones', 'completar', $id);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Consultas
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Asignaciones de una fecha (para vista de agenda general).
|
||||
*/
|
||||
public function porFecha(string $fecha = ''): array {
|
||||
$fecha = $fecha ?: date('Y-m-d');
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
a.*,
|
||||
e.nombre_completo AS enfermera_nombre,
|
||||
e.telefono AS enfermera_telefono,
|
||||
d.hora_programada,
|
||||
d.estado AS domicilio_estado,
|
||||
d.direccion,
|
||||
d.barrio,
|
||||
p.nombre_completo AS paciente_nombre,
|
||||
p.telefono AS paciente_telefono
|
||||
FROM lab_asignaciones a
|
||||
JOIN lab_enfermeras e ON e.id = a.enfermera_id
|
||||
JOIN lab_domicilios d ON d.id = a.domicilio_id
|
||||
JOIN lab_pacientes p ON p.id = d.paciente_id
|
||||
WHERE d.fecha_programada = ?
|
||||
ORDER BY e.nombre_completo, d.hora_programada ASC
|
||||
", [$fecha]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga actual del día: cuántos domicilios tiene cada enfermera hoy.
|
||||
*/
|
||||
public function cargaHoy(): array {
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
e.id,
|
||||
e.nombre_completo,
|
||||
e.telefono,
|
||||
COUNT(a.id) AS total_asignaciones,
|
||||
SUM(d.estado = 'completado') AS completados
|
||||
FROM lab_enfermeras e
|
||||
JOIN lab_asignaciones a ON a.enfermera_id = e.id
|
||||
JOIN lab_domicilios d ON d.id = a.domicilio_id
|
||||
WHERE d.fecha_programada = CURDATE()
|
||||
AND a.estado NOT IN ('liberada')
|
||||
GROUP BY e.id
|
||||
ORDER BY total_asignaciones DESC
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
/**
|
||||
* Domicilio — Gestión de servicios a domicilio del laboratorio.
|
||||
*
|
||||
* Flujo de estados:
|
||||
* programado → confirmado → en_camino → en_domicilio → completado
|
||||
* └→ cancelado / reprogramado
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
class Domicilio {
|
||||
|
||||
const ESTADOS = [
|
||||
'programado',
|
||||
'confirmado',
|
||||
'en_camino',
|
||||
'en_domicilio',
|
||||
'completado',
|
||||
'cancelado',
|
||||
'reprogramado',
|
||||
];
|
||||
|
||||
private Database $db;
|
||||
private ActividadAdmin $log;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
$this->log = new ActividadAdmin();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CRUD
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lista de domicilios con filtros.
|
||||
*
|
||||
* @param array $filtros fecha, estado, enfermera_id, paciente_id
|
||||
*/
|
||||
public function listar(array $filtros = [], int $pagina = 1, int $porPagina = 30): array {
|
||||
$wheres = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($filtros['fecha'])) {
|
||||
$wheres[] = 'd.fecha_programada = ?';
|
||||
$params[] = $filtros['fecha'];
|
||||
}
|
||||
if (!empty($filtros['desde'])) {
|
||||
$wheres[] = 'd.fecha_programada >= ?';
|
||||
$params[] = $filtros['desde'];
|
||||
}
|
||||
if (!empty($filtros['hasta'])) {
|
||||
$wheres[] = 'd.fecha_programada <= ?';
|
||||
$params[] = $filtros['hasta'];
|
||||
}
|
||||
if (!empty($filtros['estado'])) {
|
||||
$wheres[] = 'd.estado = ?';
|
||||
$params[] = $filtros['estado'];
|
||||
}
|
||||
if (!empty($filtros['enfermera_id'])) {
|
||||
$wheres[] = 'a.enfermera_id = ?';
|
||||
$params[] = $filtros['enfermera_id'];
|
||||
}
|
||||
if (!empty($filtros['paciente_id'])) {
|
||||
$wheres[] = 'd.paciente_id = ?';
|
||||
$params[] = $filtros['paciente_id'];
|
||||
}
|
||||
|
||||
$where = $wheres ? 'WHERE ' . implode(' AND ', $wheres) : '';
|
||||
$offset = ($pagina - 1) * $porPagina;
|
||||
|
||||
$total = $this->db->fetch(
|
||||
"SELECT COUNT(*) AS n
|
||||
FROM lab_domicilios d
|
||||
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
|
||||
$where",
|
||||
$params
|
||||
)['n'] ?? 0;
|
||||
|
||||
$rows = $this->db->fetchAll("
|
||||
SELECT
|
||||
d.*,
|
||||
p.nombre_completo AS paciente_nombre,
|
||||
p.telefono AS paciente_telefono,
|
||||
e.nombre_completo AS enfermera_nombre,
|
||||
e.telefono AS enfermera_telefono,
|
||||
a.id AS asignacion_id,
|
||||
a.estado AS asignacion_estado
|
||||
FROM lab_domicilios d
|
||||
JOIN lab_pacientes p ON p.id = d.paciente_id
|
||||
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
|
||||
LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id
|
||||
$where
|
||||
ORDER BY d.fecha_programada ASC, d.hora_programada ASC
|
||||
LIMIT ? OFFSET ?
|
||||
", array_merge($params, [$porPagina, $offset]));
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => (int)$total,
|
||||
'pagina' => $pagina,
|
||||
'por_pagina' => $porPagina,
|
||||
'paginas' => (int)ceil($total / $porPagina),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Domicilio por ID con todos los datos relacionados.
|
||||
*/
|
||||
public function obtener(int $id): ?array {
|
||||
return $this->db->fetch("
|
||||
SELECT
|
||||
d.*,
|
||||
p.nombre_completo AS paciente_nombre,
|
||||
p.telefono AS paciente_telefono,
|
||||
p.direccion AS paciente_direccion,
|
||||
o.examenes_solicitados,
|
||||
o.indicaciones AS indicaciones_orden,
|
||||
e.nombre_completo AS enfermera_nombre,
|
||||
e.telefono AS enfermera_telefono,
|
||||
a.id AS asignacion_id,
|
||||
a.estado AS asignacion_estado,
|
||||
adm.full_name AS creado_por_nombre
|
||||
FROM lab_domicilios d
|
||||
JOIN lab_pacientes p ON p.id = d.paciente_id
|
||||
LEFT JOIN lab_ordenes_medicas o ON o.id = d.orden_id
|
||||
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
|
||||
LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id
|
||||
LEFT JOIN admin_users adm ON adm.id = d.creado_por
|
||||
WHERE d.id = ?
|
||||
", [$id]) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear nuevo domicilio.
|
||||
*/
|
||||
public function crear(array $datos, ?int $adminId = null): int {
|
||||
$campos = $this->filtrarCampos($datos);
|
||||
$campos['creado_por'] = $adminId;
|
||||
|
||||
// Actualizar orden médica si aplica
|
||||
if (!empty($campos['orden_id'])) {
|
||||
$this->db->update(
|
||||
'lab_ordenes_medicas',
|
||||
['estado' => 'en_domicilio'],
|
||||
'id = ?', [$campos['orden_id']]
|
||||
);
|
||||
}
|
||||
|
||||
$id = (int)$this->db->insert('lab_domicilios', $campos);
|
||||
|
||||
$this->log->registrar($adminId, 'domicilios', 'crear', $id, $campos);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar domicilio.
|
||||
*/
|
||||
public function actualizar(int $id, array $datos, ?int $adminId = null): bool {
|
||||
$campos = $this->filtrarCampos($datos);
|
||||
$ok = $this->db->update('lab_domicilios', $campos, 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'domicilios', 'editar', $id, $campos);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambiar estado del domicilio.
|
||||
*/
|
||||
public function cambiarEstado(
|
||||
int $id,
|
||||
string $nuevoEstado,
|
||||
?int $adminId,
|
||||
array $extras = []
|
||||
): bool {
|
||||
if (!in_array($nuevoEstado, self::ESTADOS)) {
|
||||
throw new InvalidArgumentException("Estado inválido: $nuevoEstado");
|
||||
}
|
||||
|
||||
$campos = array_merge(['estado' => $nuevoEstado], $extras);
|
||||
$ok = $this->db->update('lab_domicilios', $campos, 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'domicilios', $nuevoEstado, $id, $extras);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Agenda del día
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Todos los domicilios de hoy (o la fecha indicada).
|
||||
*/
|
||||
public function agendaDia(string $fecha = ''): array {
|
||||
$fecha = $fecha ?: date('Y-m-d');
|
||||
return $this->listar(['fecha' => $fecha], 1, 200)['data'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Domicilios sin enfermera asignada.
|
||||
*/
|
||||
public function sinAsignar(string $fecha = ''): array {
|
||||
$fecha = $fecha ?: date('Y-m-d');
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
d.*,
|
||||
p.nombre_completo AS paciente_nombre
|
||||
FROM lab_domicilios d
|
||||
JOIN lab_pacientes p ON p.id = d.paciente_id
|
||||
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
|
||||
WHERE d.fecha_programada = ?
|
||||
AND a.id IS NULL
|
||||
AND d.estado NOT IN ('cancelado','completado')
|
||||
ORDER BY d.hora_programada ASC
|
||||
", [$fecha]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estadísticas: conteo de domicilios por estado hoy.
|
||||
*/
|
||||
public function estadisticasHoy(): array {
|
||||
$rows = $this->db->fetchAll("
|
||||
SELECT estado, COUNT(*) AS total
|
||||
FROM lab_domicilios
|
||||
WHERE fecha_programada = CURDATE()
|
||||
GROUP BY estado
|
||||
");
|
||||
$result = array_fill_keys(self::ESTADOS, 0);
|
||||
foreach ($rows as $row) {
|
||||
$result[$row['estado']] = (int)$row['total'];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function filtrarCampos(array $datos): array {
|
||||
$permitidos = [
|
||||
'orden_id', 'paciente_id', 'direccion', 'ciudad', 'barrio',
|
||||
'indicaciones_dir', 'fecha_programada', 'hora_programada',
|
||||
'tipo_servicio', 'tipo_cliente', 'examenes_solicitados',
|
||||
'estado', 'motivo_cancelacion',
|
||||
'fecha_reprogramada', 'hora_llegada', 'hora_salida',
|
||||
'observaciones', 'muestras_tomadas', 'notas_admin',
|
||||
'seguro_nombre', 'autorizacion', 'copago_laboratorio',
|
||||
'valor_domicilio', 'valor_copago',
|
||||
];
|
||||
$campos = array_intersect_key($datos, array_flip($permitidos));
|
||||
|
||||
// Campos FK que deben ser NULL cuando vienen vacíos o cero
|
||||
foreach (['orden_id', 'paciente_id'] as $fk) {
|
||||
if (array_key_exists($fk, $campos) && ($campos[$fk] === '' || $campos[$fk] === '0' || $campos[$fk] === 0)) {
|
||||
$campos[$fk] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $campos;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
/**
|
||||
* Enfermera — Gestión del personal de enfermería (domicilios).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
class Enfermera {
|
||||
|
||||
private Database $db;
|
||||
private ActividadAdmin $log;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
$this->log = new ActividadAdmin();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CRUD
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lista de enfermeras activas/todas.
|
||||
*/
|
||||
public function listar(bool $soloActivas = true): array {
|
||||
$where = $soloActivas ? 'WHERE is_active = 1' : '';
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
e.*,
|
||||
(SELECT COUNT(*) FROM lab_asignaciones a
|
||||
JOIN lab_domicilios d ON d.id = a.domicilio_id
|
||||
WHERE a.enfermera_id = e.id
|
||||
AND d.fecha_programada = CURDATE()
|
||||
AND a.estado NOT IN ('liberada','completada')
|
||||
) AS domicilios_hoy
|
||||
FROM lab_enfermeras e
|
||||
$where
|
||||
ORDER BY e.nombre_completo ASC
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Enfermera por ID.
|
||||
*/
|
||||
public function obtener(int $id): ?array {
|
||||
return $this->db->fetch(
|
||||
'SELECT * FROM lab_enfermeras WHERE id = ?',
|
||||
[$id]
|
||||
) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear enfermera.
|
||||
*/
|
||||
public function crear(array $datos, ?int $adminId = null): int {
|
||||
$campos = $this->filtrarCampos($datos);
|
||||
$id = $this->db->insert('lab_enfermeras', $campos);
|
||||
$this->log->registrar($adminId, 'enfermeras', 'crear', $id, $campos);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar enfermera.
|
||||
*/
|
||||
public function actualizar(int $id, array $datos, ?int $adminId = null): bool {
|
||||
$campos = $this->filtrarCampos($datos);
|
||||
$ok = $this->db->update('lab_enfermeras', $campos, 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'enfermeras', 'editar', $id, $campos);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desactivar (soft-delete).
|
||||
*/
|
||||
public function desactivar(int $id, ?int $adminId = null): bool {
|
||||
$ok = $this->db->update('lab_enfermeras', ['is_active' => 0], 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'enfermeras', 'desactivar', $id);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Agenda
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Agenda completa de una enfermera (domicilios asignados con detalle).
|
||||
*
|
||||
* @param int $enfermeraId
|
||||
* @param string $fecha YYYY-MM-DD, por defecto hoy
|
||||
*/
|
||||
public function agenda(int $enfermeraId, string $fecha = ''): array {
|
||||
$fecha = $fecha ?: date('Y-m-d');
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
d.id AS domicilio_id,
|
||||
d.fecha_programada,
|
||||
d.hora_programada,
|
||||
d.estado AS domicilio_estado,
|
||||
d.direccion,
|
||||
d.barrio,
|
||||
d.tipo_servicio,
|
||||
p.nombre_completo AS paciente_nombre,
|
||||
p.telefono AS paciente_telefono,
|
||||
a.estado AS asignacion_estado,
|
||||
a.id AS asignacion_id
|
||||
FROM lab_asignaciones a
|
||||
JOIN lab_domicilios d ON d.id = a.domicilio_id
|
||||
JOIN lab_pacientes p ON p.id = d.paciente_id
|
||||
WHERE a.enfermera_id = ?
|
||||
AND d.fecha_programada = ?
|
||||
ORDER BY d.hora_programada ASC
|
||||
", [$enfermeraId, $fecha]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga de trabajo por período: domicilios asignados por día.
|
||||
*/
|
||||
public function cargaTrabajo(int $enfermeraId, string $desde, string $hasta): array {
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
d.fecha_programada,
|
||||
COUNT(*) AS total,
|
||||
SUM(d.estado = 'completado') AS completados
|
||||
FROM lab_asignaciones a
|
||||
JOIN lab_domicilios d ON d.id = a.domicilio_id
|
||||
WHERE a.enfermera_id = ?
|
||||
AND d.fecha_programada BETWEEN ? AND ?
|
||||
GROUP BY d.fecha_programada
|
||||
ORDER BY d.fecha_programada ASC
|
||||
", [$enfermeraId, $desde, $hasta]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function filtrarCampos(array $datos): array {
|
||||
$permitidos = [
|
||||
'numero_documento', 'tipo_documento', 'nombre_completo',
|
||||
'telefono', 'telefono_alt', 'email',
|
||||
'zona', 'notas', 'is_active',
|
||||
];
|
||||
return array_intersect_key($datos, array_flip($permitidos));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
/**
|
||||
* Formulario — Gestión de plantillas y envíos de formularios clínicos.
|
||||
*/
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
class Formulario {
|
||||
|
||||
private Database $db;
|
||||
private ActividadAdmin $log;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
$this->log = new ActividadAdmin();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// PLANTILLAS (admin)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function listar(bool $soloActivos = true): array {
|
||||
$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) 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
|
||||
ORDER BY f.created_at DESC
|
||||
");
|
||||
}
|
||||
|
||||
public function obtener(int $id): ?array {
|
||||
$row = $this->db->fetch(
|
||||
"SELECT f.*, u.full_name AS creado_por_nombre
|
||||
FROM lab_formularios f
|
||||
LEFT JOIN admin_users u ON u.id = f.creado_por
|
||||
WHERE f.id = ?",
|
||||
[$id]
|
||||
);
|
||||
if ($row) {
|
||||
$row['esquema_decoded'] = json_decode($row['esquema'], true) ?? [];
|
||||
}
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public function crear(array $datos, int $adminId): int {
|
||||
$this->validarEsquema($datos['esquema'] ?? '[]');
|
||||
$id = $this->db->insert('lab_formularios', [
|
||||
'nombre' => trim($datos['nombre']),
|
||||
'descripcion' => $datos['descripcion'] ?? null,
|
||||
'categoria' => $datos['categoria'] ?? 'otro',
|
||||
'esquema' => $datos['esquema'] ?? '[]',
|
||||
'permite_firma' => !empty($datos['permite_firma']) ? 1 : 0,
|
||||
'requiere_firma' => !empty($datos['requiere_firma']) ? 1 : 0,
|
||||
'doc_encabezado' => $datos['doc_encabezado'] ?? null,
|
||||
'doc_subtitulo' => $datos['doc_subtitulo'] ?? null,
|
||||
'doc_logo_base64' => $datos['doc_logo_base64'] ?? null,
|
||||
'doc_color' => $datos['doc_color'] ?? null,
|
||||
'doc_pie_pagina' => $datos['doc_pie_pagina'] ?? null,
|
||||
'creado_por' => $adminId,
|
||||
]);
|
||||
$this->log->registrar($adminId, 'formularios', 'crear', $id, ['nombre' => $datos['nombre']]);
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function actualizar(int $id, array $datos, int $adminId): bool {
|
||||
$permitidos = [
|
||||
'nombre','descripcion','categoria','esquema',
|
||||
'permite_firma','requiere_firma','is_active',
|
||||
'doc_encabezado','doc_subtitulo','doc_logo_base64','doc_color','doc_pie_pagina',
|
||||
];
|
||||
$campos = array_intersect_key($datos, array_flip($permitidos));
|
||||
if (isset($campos['esquema'])) {
|
||||
$this->validarEsquema($campos['esquema']);
|
||||
$campos['version'] = (int)$this->db->fetch('SELECT version FROM lab_formularios WHERE id = ?', [$id])['version'] + 1;
|
||||
}
|
||||
$ok = $this->db->update('lab_formularios', $campos, 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'formularios', 'editar', $id, $campos);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// ENVÍOS
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crear un envío con datos pre-llenados, genera token único.
|
||||
*/
|
||||
public function crearEnvio(array $datos, int $enviadoPor): int {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expira = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||
|
||||
$id = $this->db->insert('lab_form_envios', [
|
||||
'formulario_id' => (int)$datos['formulario_id'],
|
||||
'paciente_id' => !empty($datos['paciente_id']) ? (int)$datos['paciente_id'] : null,
|
||||
'domicilio_id' => !empty($datos['domicilio_id']) ? (int)$datos['domicilio_id'] : null,
|
||||
'token' => $token,
|
||||
'datos_prefilled' => $datos['datos_prefilled'] ?? '{}',
|
||||
'estado' => 'pendiente',
|
||||
'enviado_por' => $enviadoPor,
|
||||
'enviado_via' => $datos['enviado_via'] ?? 'whatsapp',
|
||||
'expira_en' => $expira,
|
||||
]);
|
||||
$this->log->registrar($enviadoPor, 'formularios', 'enviar', $id, [
|
||||
'formulario_id' => $datos['formulario_id'],
|
||||
'via' => $datos['enviado_via'] ?? 'whatsapp',
|
||||
]);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener envío por token (uso público del cliente).
|
||||
*/
|
||||
public function obtenerPorToken(string $token): ?array {
|
||||
$envio = $this->db->fetch(
|
||||
"SELECT e.*, f.nombre AS form_nombre, f.esquema, f.permite_firma, f.requiere_firma, f.descripcion AS form_descripcion,
|
||||
f.doc_color, f.doc_logo_base64, f.doc_encabezado, f.doc_subtitulo, f.doc_pie_pagina,
|
||||
p.nombre_completo AS paciente_nombre, p.numero_documento, p.telefono AS paciente_telefono
|
||||
FROM lab_form_envios e
|
||||
JOIN lab_formularios f ON f.id = e.formulario_id
|
||||
LEFT JOIN lab_pacientes p ON p.id = e.paciente_id
|
||||
WHERE e.token = ?",
|
||||
[$token]
|
||||
);
|
||||
if (!$envio) return null;
|
||||
if ($envio['estado'] === 'expirado') return null;
|
||||
if ($envio['expira_en'] && strtotime($envio['expira_en']) < time()) {
|
||||
$this->db->update('lab_form_envios', ['estado' => 'expirado'], 'id = ?', [$envio['id']]);
|
||||
return null;
|
||||
}
|
||||
$envio['esquema_decoded'] = json_decode($envio['esquema'], true) ?? [];
|
||||
$envio['prefilled_decoded'] = json_decode($envio['datos_prefilled'], true) ?? [];
|
||||
return $envio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardar respuesta del cliente (firma + datos).
|
||||
*/
|
||||
public function guardarRespuesta(string $token, array $datosCliente, ?string $firmaSvg, string $ip, string $ua): bool {
|
||||
$envio = $this->db->fetch('SELECT id, estado FROM lab_form_envios WHERE token = ?', [$token]);
|
||||
if (!$envio || $envio['estado'] === 'expirado') return false;
|
||||
|
||||
$tieneCanvas = !empty($firmaSvg);
|
||||
$tieneFoto = !empty($datosCliente['__firma_foto']);
|
||||
$estado = ($tieneCanvas || $tieneFoto) ? 'firmado' : 'completado';
|
||||
|
||||
// Hash de verificación SHA-256 (vincula contenido + firma + timestamp)
|
||||
$payloadHash = json_encode($datosCliente, JSON_UNESCAPED_UNICODE)
|
||||
. ($firmaSvg ?? '')
|
||||
. $envio['id']
|
||||
. $envio['token']
|
||||
. date('Y-m-d H:i:s');
|
||||
$hash = hash('sha256', $payloadHash);
|
||||
|
||||
$this->db->update('lab_form_envios', [
|
||||
'datos_cliente' => json_encode($datosCliente, JSON_UNESCAPED_UNICODE),
|
||||
'firma_svg' => $firmaSvg,
|
||||
'hash_verificacion' => $hash,
|
||||
'ip_cliente' => $ip,
|
||||
'user_agent' => substr($ua, 0, 512),
|
||||
'estado' => $estado,
|
||||
'completado_en' => date('Y-m-d H:i:s'),
|
||||
], 'token = ?', [$token]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar envíos (admin/enfermero).
|
||||
*/
|
||||
public function listarEnvios(array $filtros = []): array {
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
if (!empty($filtros['formulario_id'])) { $where[] = 'e.formulario_id = ?'; $params[] = $filtros['formulario_id']; }
|
||||
if (!empty($filtros['paciente_id'])) { $where[] = 'e.paciente_id = ?'; $params[] = $filtros['paciente_id']; }
|
||||
if (!empty($filtros['enviado_por'])) { $where[] = 'e.enviado_por = ?'; $params[] = $filtros['enviado_por']; }
|
||||
if (!empty($filtros['estado'])) { $where[] = 'e.estado = ?'; $params[] = $filtros['estado']; }
|
||||
$w = implode(' AND ', $where);
|
||||
return $this->db->fetchAll("
|
||||
SELECT e.*, f.nombre AS form_nombre, f.categoria, f.esquema,
|
||||
p.nombre_completo AS paciente_nombre,
|
||||
u.full_name AS enviado_por_nombre
|
||||
FROM lab_form_envios e
|
||||
JOIN lab_formularios f ON f.id = e.formulario_id
|
||||
LEFT JOIN lab_pacientes p ON p.id = e.paciente_id
|
||||
LEFT JOIN admin_users u ON u.id = e.enviado_por
|
||||
WHERE $w
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 200
|
||||
", $params);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helper
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
private function validarEsquema(string $json): void {
|
||||
$decoded = json_decode($json, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new InvalidArgumentException('El esquema del formulario no es un JSON válido');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
/**
|
||||
* OrdenMedica — Gestión de órdenes médicas recibidas por WhatsApp.
|
||||
*
|
||||
* Flujo de estados:
|
||||
* pendiente → en_revision → autorizada → en_domicilio → completada
|
||||
* └→ rechazada
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
class OrdenMedica {
|
||||
|
||||
// Estados válidos
|
||||
const ESTADOS = [
|
||||
'pendiente',
|
||||
'en_revision',
|
||||
'autorizada',
|
||||
'rechazada',
|
||||
'en_domicilio',
|
||||
'completada',
|
||||
];
|
||||
|
||||
private Database $db;
|
||||
private ActividadAdmin $log;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
$this->log = new ActividadAdmin();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CRUD
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lista paginada de órdenes con filtros.
|
||||
*
|
||||
* @param array $filtros estado, paciente_id, desde, hasta, busqueda
|
||||
*/
|
||||
public function listar(array $filtros = [], int $pagina = 1, int $porPagina = 30): array {
|
||||
$wheres = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($filtros['estado'])) {
|
||||
$wheres[] = 'o.estado = ?';
|
||||
$params[] = $filtros['estado'];
|
||||
}
|
||||
if (!empty($filtros['paciente_id'])) {
|
||||
$wheres[] = 'o.paciente_id = ?';
|
||||
$params[] = $filtros['paciente_id'];
|
||||
}
|
||||
if (!empty($filtros['desde'])) {
|
||||
$wheres[] = 'DATE(o.created_at) >= ?';
|
||||
$params[] = $filtros['desde'];
|
||||
}
|
||||
if (!empty($filtros['hasta'])) {
|
||||
$wheres[] = 'DATE(o.created_at) <= ?';
|
||||
$params[] = $filtros['hasta'];
|
||||
}
|
||||
if (!empty($filtros['busqueda'])) {
|
||||
$like = '%' . $filtros['busqueda'] . '%';
|
||||
$wheres[] = '(p.nombre_completo LIKE ? OR p.numero_documento LIKE ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
$where = $wheres ? 'WHERE ' . implode(' AND ', $wheres) : '';
|
||||
$offset = ($pagina - 1) * $porPagina;
|
||||
|
||||
$total = $this->db->fetch(
|
||||
"SELECT COUNT(*) AS n
|
||||
FROM lab_ordenes_medicas o
|
||||
JOIN lab_pacientes p ON p.id = o.paciente_id
|
||||
$where",
|
||||
$params
|
||||
)['n'] ?? 0;
|
||||
|
||||
$rows = $this->db->fetchAll("
|
||||
SELECT
|
||||
o.*,
|
||||
p.nombre_completo AS paciente_nombre,
|
||||
p.numero_documento,
|
||||
p.telefono AS paciente_telefono,
|
||||
rev.full_name AS revisada_por_nombre,
|
||||
aut.full_name AS autorizada_por_nombre
|
||||
FROM lab_ordenes_medicas o
|
||||
JOIN lab_pacientes p ON p.id = o.paciente_id
|
||||
LEFT JOIN admin_users rev ON rev.id = o.revisada_por
|
||||
LEFT JOIN admin_users aut ON aut.id = o.autorizada_por
|
||||
$where
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
", array_merge($params, [$porPagina, $offset]));
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => (int)$total,
|
||||
'pagina' => $pagina,
|
||||
'por_pagina' => $porPagina,
|
||||
'paginas' => (int)ceil($total / $porPagina),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener una orden por ID (con todos los datos relacionados).
|
||||
*/
|
||||
public function obtener(int $id): ?array {
|
||||
$orden = $this->db->fetch("
|
||||
SELECT
|
||||
o.*,
|
||||
p.nombre_completo AS paciente_nombre,
|
||||
p.numero_documento,
|
||||
p.telefono AS paciente_telefono,
|
||||
p.eps AS paciente_eps,
|
||||
p.user_id,
|
||||
u.phone_number,
|
||||
rev.full_name AS revisada_por_nombre,
|
||||
aut.full_name AS autorizada_por_nombre,
|
||||
c.local_file AS conv_local_file,
|
||||
c.whatsapp_media_id AS conv_media_id
|
||||
FROM lab_ordenes_medicas o
|
||||
JOIN lab_pacientes p ON p.id = o.paciente_id
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
LEFT JOIN admin_users rev ON rev.id = o.revisada_por
|
||||
LEFT JOIN admin_users aut ON aut.id = o.autorizada_por
|
||||
LEFT JOIN conversations c ON c.id = o.conversation_id
|
||||
WHERE o.id = ?
|
||||
", [$id]);
|
||||
|
||||
if (!$orden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Historial de cambios de estado
|
||||
$orden['historial'] = $this->db->fetchAll("
|
||||
SELECT
|
||||
la.*,
|
||||
au.full_name AS admin_nombre
|
||||
FROM lab_autorizaciones la
|
||||
LEFT JOIN admin_users au ON au.id = la.realizada_por
|
||||
WHERE la.orden_id = ?
|
||||
ORDER BY la.created_at ASC
|
||||
", [$id]);
|
||||
|
||||
return $orden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear nueva orden médica.
|
||||
*/
|
||||
public function crear(array $datos, ?int $adminId = null): int {
|
||||
$campos = $this->filtrarCampos($datos);
|
||||
$id = $this->db->insert('lab_ordenes_medicas', $campos);
|
||||
|
||||
// Registrar en historial
|
||||
$this->registrarCambioEstado($id, null, 'pendiente', $adminId, 'Orden creada');
|
||||
$this->log->registrar($adminId, 'ordenes', 'crear', $id, $campos);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar datos de la orden (sin cambiar estado).
|
||||
*/
|
||||
public function actualizar(int $id, array $datos, ?int $adminId = null): bool {
|
||||
$campos = $this->filtrarCampos($datos);
|
||||
unset($campos['estado']); // el estado se cambia con cambiarEstado()
|
||||
$ok = $this->db->update('lab_ordenes_medicas', $campos, 'id = ?', [$id]);
|
||||
if ($ok) {
|
||||
$this->registrarCambioEstado($id, null, null, $adminId, 'Datos actualizados', 'editada');
|
||||
$this->log->registrar($adminId, 'ordenes', 'editar', $id, $campos);
|
||||
}
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Gestión de estados (flujo de revisión)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cambia el estado de la orden con trazabilidad completa.
|
||||
*
|
||||
* @param int $id
|
||||
* @param string $nuevoEstado Debe estar en self::ESTADOS
|
||||
* @param int $adminId
|
||||
* @param string $comentario
|
||||
*/
|
||||
public function cambiarEstado(
|
||||
int $id,
|
||||
string $nuevoEstado,
|
||||
int $adminId,
|
||||
string $comentario = ''
|
||||
): bool {
|
||||
if (!in_array($nuevoEstado, self::ESTADOS)) {
|
||||
throw new InvalidArgumentException("Estado inválido: $nuevoEstado");
|
||||
}
|
||||
|
||||
$orden = $this->obtener($id);
|
||||
if (!$orden) {
|
||||
throw new RuntimeException("Orden #$id no encontrada");
|
||||
}
|
||||
|
||||
$estadoAnterior = $orden['estado'];
|
||||
$ahora = date('Y-m-d H:i:s');
|
||||
|
||||
$campos = ['estado' => $nuevoEstado];
|
||||
|
||||
// Campos adicionales según el nuevo estado
|
||||
if ($nuevoEstado === 'en_revision') {
|
||||
$campos['revisada_por'] = $adminId;
|
||||
$campos['revisada_at'] = $ahora;
|
||||
} elseif (in_array($nuevoEstado, ['autorizada', 'rechazada'])) {
|
||||
$campos['autorizada_por'] = $adminId;
|
||||
$campos['autorizada_at'] = $ahora;
|
||||
$campos['comentario_revision'] = $comentario;
|
||||
}
|
||||
|
||||
$ok = $this->db->update('lab_ordenes_medicas', $campos, 'id = ?', [$id]);
|
||||
|
||||
if ($ok) {
|
||||
$this->registrarCambioEstado($id, $estadoAnterior, $nuevoEstado, $adminId, $comentario);
|
||||
$this->log->registrar($adminId, 'ordenes', $nuevoEstado, $id, [
|
||||
'estado_anterior' => $estadoAnterior,
|
||||
'comentario' => $comentario,
|
||||
]);
|
||||
}
|
||||
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
// Alias de conveniencia
|
||||
public function autorizar(int $id, int $adminId, string $comentario = ''): bool {
|
||||
return $this->cambiarEstado($id, 'autorizada', $adminId, $comentario);
|
||||
}
|
||||
|
||||
public function rechazar(int $id, int $adminId, string $motivo): bool {
|
||||
return $this->cambiarEstado($id, 'rechazada', $adminId, $motivo);
|
||||
}
|
||||
|
||||
public function iniciarRevision(int $id, int $adminId): bool {
|
||||
return $this->cambiarEstado($id, 'en_revision', $adminId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Estadísticas / Dashboard
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Conteo de órdenes por estado.
|
||||
*/
|
||||
public function contadorPorEstado(): array {
|
||||
$rows = $this->db->fetchAll("
|
||||
SELECT estado, COUNT(*) AS total
|
||||
FROM lab_ordenes_medicas
|
||||
GROUP BY estado
|
||||
");
|
||||
$result = array_fill_keys(self::ESTADOS, 0);
|
||||
foreach ($rows as $row) {
|
||||
$result[$row['estado']] = (int)$row['total'];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Órdenes pendientes más antiguas (sin revisar).
|
||||
*/
|
||||
public function pendientesAntiguos(int $limit = 10): array {
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
o.*,
|
||||
p.nombre_completo,
|
||||
TIMESTAMPDIFF(HOUR, o.created_at, NOW()) AS horas_espera
|
||||
FROM lab_ordenes_medicas o
|
||||
JOIN lab_pacientes p ON p.id = o.paciente_id
|
||||
WHERE o.estado = 'pendiente'
|
||||
ORDER BY o.created_at ASC
|
||||
LIMIT ?
|
||||
", [$limit]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers privados
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function registrarCambioEstado(
|
||||
int $ordenId,
|
||||
?string $estadoAnterior,
|
||||
?string $estadoNuevo,
|
||||
?int $adminId,
|
||||
string $comentario = '',
|
||||
string $accion = ''
|
||||
): void {
|
||||
$accion = $accion ?: ($estadoNuevo ?? 'editada');
|
||||
$this->db->insert('lab_autorizaciones', [
|
||||
'orden_id' => $ordenId,
|
||||
'accion' => $accion,
|
||||
'estado_anterior'=> $estadoAnterior,
|
||||
'estado_nuevo' => $estadoNuevo,
|
||||
'realizada_por' => $adminId,
|
||||
'comentario' => $comentario,
|
||||
]);
|
||||
}
|
||||
|
||||
private function filtrarCampos(array $datos): array {
|
||||
$permitidos = [
|
||||
'paciente_id', 'conversation_id', 'whatsapp_media_id', 'local_file',
|
||||
'estado', 'medico_nombre', 'medico_registro', 'fecha_orden',
|
||||
'diagnostico', 'examenes_solicitados', 'requiere_ayuno',
|
||||
'horas_ayuno', 'indicaciones', 'revisada_por', 'revisada_at',
|
||||
'autorizada_por', 'autorizada_at', 'comentario_revision', 'notas_admin',
|
||||
];
|
||||
return array_intersect_key($datos, array_flip($permitidos));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
/**
|
||||
* Paciente — Gestión de pacientes del módulo administrativo de laboratorio.
|
||||
* Extiende la información del contacto WhatsApp (users.id).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
class Paciente {
|
||||
|
||||
private Database $db;
|
||||
private ActividadAdmin $log;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = Database::getInstance();
|
||||
$this->log = new ActividadAdmin();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CRUD
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lista paginada de pacientes con búsqueda.
|
||||
*/
|
||||
public function listar(
|
||||
string $busqueda = '',
|
||||
int $pagina = 1,
|
||||
int $porPagina = 30
|
||||
): array {
|
||||
$offset = ($pagina - 1) * $porPagina;
|
||||
$like = "%$busqueda%";
|
||||
$params = $busqueda
|
||||
? [$like, $like, $like, $like]
|
||||
: [];
|
||||
|
||||
$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",
|
||||
$params
|
||||
)['n'] ?? 0;
|
||||
|
||||
$rows = $this->db->fetchAll("
|
||||
SELECT
|
||||
p.*,
|
||||
u.phone_number,
|
||||
u.name AS whatsapp_name,
|
||||
(SELECT COUNT(*) FROM lab_ordenes_medicas o WHERE o.paciente_id = p.id) AS total_ordenes
|
||||
FROM lab_pacientes p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
$where
|
||||
ORDER BY p.nombre_completo ASC
|
||||
LIMIT ? OFFSET ?
|
||||
", array_merge($params, [$porPagina, $offset]));
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => (int)$total,
|
||||
'pagina' => $pagina,
|
||||
'por_pagina' => $porPagina,
|
||||
'paginas' => (int)ceil($total / $porPagina),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Un paciente por ID (con datos del usuario WhatsApp).
|
||||
*/
|
||||
public function obtener(int $id): ?array {
|
||||
return $this->db->fetch("
|
||||
SELECT
|
||||
p.*,
|
||||
u.phone_number,
|
||||
u.name AS whatsapp_name,
|
||||
u.status AS whatsapp_status,
|
||||
u.created_at AS whatsapp_desde
|
||||
FROM lab_pacientes p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.id = ?
|
||||
", [$id]) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar por número de documento.
|
||||
*/
|
||||
public function porDocumento(string $documento): ?array {
|
||||
return $this->db->fetch(
|
||||
'SELECT * FROM lab_pacientes WHERE numero_documento = ?',
|
||||
[$documento]
|
||||
) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar por user_id (teléfono WhatsApp).
|
||||
*/
|
||||
public function porUserId(int $userId): ?array {
|
||||
return $this->db->fetch(
|
||||
'SELECT * FROM lab_pacientes WHERE user_id = ?',
|
||||
[$userId]
|
||||
) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear paciente nuevo.
|
||||
*
|
||||
* @param array $datos Campos del formulario
|
||||
* @param int|null $adminId Quien lo crea (para trazabilidad)
|
||||
* @return int ID del paciente creado
|
||||
*/
|
||||
public function crear(array $datos, ?int $adminId = null): int {
|
||||
$campos = $this->filtrarCampos($datos);
|
||||
$id = $this->db->insert('lab_pacientes', $campos);
|
||||
$this->log->registrar($adminId, 'pacientes', 'crear', $id, $campos);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar paciente existente.
|
||||
*/
|
||||
public function actualizar(int $id, array $datos, ?int $adminId = null): bool {
|
||||
$campos = $this->filtrarCampos($datos);
|
||||
$ok = $this->db->update('lab_pacientes', $campos, 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $campos);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desactivar (soft-delete).
|
||||
*/
|
||||
public function desactivar(int $id, ?int $adminId = null): bool {
|
||||
$ok = $this->db->update('lab_pacientes', ['is_active' => 0], 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'pacientes', 'desactivar', $id);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vincular paciente con un usuario de WhatsApp.
|
||||
*/
|
||||
public function vincularUsuario(int $pacienteId, int $userId, ?int $adminId = null): bool {
|
||||
$ok = $this->db->update('lab_pacientes', ['user_id' => $userId], 'id = ?', [$pacienteId]);
|
||||
$this->log->registrar($adminId, 'pacientes', 'vincular_whatsapp', $pacienteId, [
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Historial
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Órdenes médicas del paciente.
|
||||
*/
|
||||
public function ordenes(int $pacienteId, int $limit = 20): array {
|
||||
return $this->db->fetchAll("
|
||||
SELECT
|
||||
o.*,
|
||||
au.full_name AS autorizada_por_nombre
|
||||
FROM lab_ordenes_medicas o
|
||||
LEFT JOIN admin_users au ON au.id = o.autorizada_por
|
||||
WHERE o.paciente_id = ?
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT ?
|
||||
", [$pacienteId, $limit]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Historial de actividad de trazabilidad del paciente.
|
||||
*/
|
||||
public function actividad(int $pacienteId): array {
|
||||
return $this->log->porEntidad('pacientes', $pacienteId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crea automáticamente un paciente a partir de un users.id (whatsapp)
|
||||
* si aún no existe. Devuelve el id del paciente.
|
||||
*/
|
||||
public function obtenerOCrearDesdeWhatsapp(int $userId): int {
|
||||
$existente = $this->porUserId($userId);
|
||||
if ($existente) {
|
||||
return $existente['id'];
|
||||
}
|
||||
|
||||
$user = $this->db->fetch(
|
||||
'SELECT * FROM users WHERE id = ?',
|
||||
[$userId]
|
||||
);
|
||||
|
||||
return $this->crear([
|
||||
'user_id' => $userId,
|
||||
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
|
||||
'telefono' => $user['phone_number'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function filtrarCampos(array $datos): array {
|
||||
$permitidos = [
|
||||
'user_id', 'numero_documento', 'tipo_documento',
|
||||
'nombre_completo', 'telefono', 'email',
|
||||
'fecha_nacimiento', 'genero', 'direccion',
|
||||
'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active',
|
||||
];
|
||||
return array_intersect_key($datos, array_flip($permitidos));
|
||||
}
|
||||
}
|
||||
+108
-6
@@ -18,6 +18,23 @@ define('DB_CHARSET', 'utf8mb4');
|
||||
// El sistema ahora usa autenticación basada en base de datos (admin_users table)
|
||||
define('ADMIN_PASSWORD', '$2y$10$IXCY8Sm1xFkfhC6Y67Ahn.QLHxE.sjWfmTEOKFZdCN2a9s9YLVuGW'); // password123
|
||||
|
||||
// ── Módulos del sistema ──────────────────────────────────────────────────────
|
||||
// Catálogo de todos los módulos disponibles para asignar a los roles.
|
||||
// Clave: slug interno · Valor: etiqueta legible para la UI.
|
||||
define('SYSTEM_MODULES', [
|
||||
'whatsapp' => 'WhatsApp Bot',
|
||||
'lab_dashboard' => 'Dashboard Lab',
|
||||
'lab_ordenes' => 'Órdenes Médicas',
|
||||
'lab_pacientes' => 'Pacientes',
|
||||
'lab_domicilios' => 'Domicilios',
|
||||
'lab_enfermeras' => 'Enfermeras',
|
||||
'lab_formularios' => 'Formularios',
|
||||
'lab_reportes' => 'Reportes',
|
||||
'lab_configuracion' => 'Configuración Lab',
|
||||
'usuarios' => 'Gestión de Usuarios',
|
||||
'enfermero_portal' => 'Portal Enfermero',
|
||||
]);
|
||||
|
||||
// Función para cargar archivo .env
|
||||
if (!function_exists('loadEnvFile')) {
|
||||
function loadEnvFile($path) {
|
||||
@@ -355,8 +372,13 @@ define('WEBHOOK_VERIFY_TOKEN', getConfigFromDB('webhook_verify_token', env('WEBH
|
||||
|
||||
// Configuración general
|
||||
define('APP_NAME', 'WhatsApp Bot System');
|
||||
define('APP_VERSION', '1.0.0');
|
||||
define('APP_URL', 'https://tudominio.com'); // Se auto-detectará
|
||||
define('APP_VERSION', '2.0.0');
|
||||
// URL auto-detectada según protocolo y host actuales
|
||||
if (!defined('APP_URL')) {
|
||||
$__proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$__host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
define('APP_URL', $__proto . '://' . $__host);
|
||||
}
|
||||
define('TIMEZONE', 'America/Bogota');
|
||||
|
||||
// Información del desarrollador
|
||||
@@ -463,11 +485,29 @@ function authenticateUser($username, $password) {
|
||||
$updateLogin = $pdo->prepare("UPDATE admin_users SET last_login = NOW() WHERE id = ?");
|
||||
$updateLogin->execute([$admin['id']]);
|
||||
|
||||
// Cargar módulos del rol
|
||||
$modules = [];
|
||||
$roleSlug = $admin['role'] ?? 'admin';
|
||||
if (!empty($admin['role_id'])) {
|
||||
$modStmt = $pdo->prepare("SELECT module_slug FROM role_modules WHERE role_id = ?");
|
||||
$modStmt->execute([$admin['role_id']]);
|
||||
$modules = array_column($modStmt->fetchAll(PDO::FETCH_ASSOC), 'module_slug');
|
||||
} elseif ($roleSlug === 'admin') {
|
||||
// Fallback: admin sin role_id tiene todos los módulos
|
||||
$modules = array_keys(SYSTEM_MODULES);
|
||||
} elseif ($roleSlug === 'enfermero') {
|
||||
$modules = ['enfermero_portal', 'lab_formularios'];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $admin['id'],
|
||||
'username' => $admin['username'],
|
||||
'full_name' => $admin['full_name'],
|
||||
'email' => $admin['email']
|
||||
'id' => $admin['id'],
|
||||
'username' => $admin['username'],
|
||||
'full_name' => $admin['full_name'],
|
||||
'email' => $admin['email'],
|
||||
'role' => $roleSlug,
|
||||
'role_id' => $admin['role_id'] ?? null,
|
||||
'enfermera_id' => $admin['enfermera_id'] ?? null,
|
||||
'modules' => $modules,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
@@ -534,6 +574,58 @@ function getAdminUsers() {
|
||||
|
||||
// Función para verificar login
|
||||
if (!function_exists('isUserLoggedIn')) {
|
||||
/**
|
||||
* Verifica si el usuario autenticado tiene el rol indicado.
|
||||
*/
|
||||
function userHasRole(string $role): bool {
|
||||
return isset($_SESSION['admin_user']['role']) && $_SESSION['admin_user']['role'] === $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verdadero si el usuario es enfermero.
|
||||
*/
|
||||
function isEnfermero(): bool {
|
||||
return userHasRole('enfermero');
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve el ID de enfermera del usuario en sesión, o null si no aplica.
|
||||
*/
|
||||
function enfermeraId(): ?int {
|
||||
$id = $_SESSION['admin_user']['enfermera_id'] ?? null;
|
||||
return $id ? (int)$id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprueba si el usuario en sesión tiene acceso al módulo indicado.
|
||||
* Los administradores sin modules cargados tienen acceso total (retrocompat.).
|
||||
*/
|
||||
function hasModule(string $slug): bool {
|
||||
$modules = $_SESSION['admin_user']['modules'] ?? null;
|
||||
// Si no hay módulos en sesión (usuario legacy) y es admin → acceso total
|
||||
if ($modules === null) {
|
||||
return userHasRole('admin');
|
||||
}
|
||||
return in_array($slug, $modules, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detiene la ejecución si el usuario no tiene el rol requerido.
|
||||
*/
|
||||
function requireRole(string $role): void {
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
if (!userHasRole($role)) {
|
||||
http_response_code(403);
|
||||
// Redirigir al portal correcto
|
||||
$destino = userHasRole('enfermero') ? 'enfermero_portal.php' : 'index.php';
|
||||
header("Location: $destino");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function isUserLoggedIn() {
|
||||
return isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true;
|
||||
}
|
||||
@@ -659,6 +751,14 @@ function saveWhatsAppConfigToDB($whatsappConfig) {
|
||||
$success &= saveConfigToDB('welcome_message', $whatsappConfig['welcome_message']);
|
||||
}
|
||||
|
||||
if (isset($whatsappConfig['terms_message'])) {
|
||||
$success &= saveConfigToDB('terms_message', $whatsappConfig['terms_message']);
|
||||
}
|
||||
|
||||
if (isset($whatsappConfig['terms_rejected_message'])) {
|
||||
$success &= saveConfigToDB('terms_rejected_message', $whatsappConfig['terms_rejected_message']);
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
@@ -694,6 +794,8 @@ function getWhatsAppConfigFromDB() {
|
||||
'webhook_verify_token' => $webhookToken,
|
||||
'business_name' => getConfigFromDB('business_name', ''),
|
||||
'welcome_message' => getConfigFromDB('welcome_message', ''),
|
||||
'terms_message' => getConfigFromDB('terms_message', ''),
|
||||
'terms_rejected_message' => getConfigFromDB('terms_rejected_message', ''),
|
||||
'status' => validateWhatsAppConfigFromDB()
|
||||
];
|
||||
}
|
||||
|
||||
+939
-1
@@ -907,6 +907,10 @@ if (!isset($_SESSION['user_id'])) {
|
||||
<div class="chat-header-info">
|
||||
<h6 id="chat-name"><span id="chat-name-text">Usuario</span> <button class="btn btn-sm btn-link" id="edit-user-btn" title="Editar usuario"><i class="fas fa-user-edit"></i></button>
|
||||
<span id="hold-indicator" style="display:none; margin-left:8px; color:#b85; font-weight:600">EN ESPERA</span>
|
||||
<span id="terms-pending-badge" title="Este usuario aún no ha aceptado los Términos y Condiciones"
|
||||
style="display:none; margin-left:8px; background:#dc3545; color:#fff; font-size:10px; font-weight:600; padding:2px 7px; border-radius:10px; vertical-align:middle; cursor:default;">
|
||||
<i class="fas fa-file-contract me-1"></i>T&C Pendiente
|
||||
</span>
|
||||
</h6>
|
||||
<small id="chat-phone">+1234567890</small>
|
||||
<div style="font-size:12px; margin-top:4px;">
|
||||
@@ -919,6 +923,18 @@ if (!isset($_SESSION['user_id'])) {
|
||||
<div id="bot-toggle-container" style="display:flex;align-items:center;gap:6px;margin-right:8px;">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="bot-toggle">Bot: On</button>
|
||||
</div>
|
||||
<!-- Botón ver citas del paciente -->
|
||||
<button class="btn btn-sm btn-outline-warning" id="ver-citas-btn" title="Ver citas de este paciente" onclick="citasModal.abrir()">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</button>
|
||||
<!-- Botón agendar domicilio rápido (sin imagen) -->
|
||||
<button class="btn btn-sm btn-outline-success" id="agendar-rapido-btn" title="Agendar domicilio" onclick="labDomicilio.abrirRapido()">
|
||||
<i class="fas fa-house-medical"></i>
|
||||
</button>
|
||||
<!-- Botón ir a gestión de domicilios (asignar enfermeras) -->
|
||||
<a href="lab_domicilios.php" target="_blank" class="btn btn-sm btn-outline-primary" title="Gestión de domicilios — asignar enfermeras">
|
||||
<i class="fas fa-user-nurse"></i>
|
||||
</a>
|
||||
<!-- Botón solicitar archivo grande -->
|
||||
<button class="btn btn-sm btn-outline-info" id="request-large-file-btn" title="Solicitar archivo grande al cliente">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
@@ -1037,7 +1053,7 @@ if (!isset($_SESSION['user_id'])) {
|
||||
console.log('%c═══════════════════════════════════════════', 'color: #25d366; font-weight: bold;');
|
||||
console.log('%c🚀 WHATSAPP BOT v1.4.0 - Desarrollado por U-Site.app', 'background: #25d366; color: white; padding: 10px 20px; font-size: 18px; font-weight: bold; border-radius: 5px;');
|
||||
console.log('%c═══════════════════════════════════════════', 'color: #25d366; font-weight: bold;');
|
||||
window.__APP_VERSION__ = '1.4.0';
|
||||
window.__APP_VERSION__ = '2.0.0';
|
||||
</script>
|
||||
|
||||
<?php
|
||||
@@ -2964,6 +2980,12 @@ if (!isset($_SESSION['user_id'])) {
|
||||
console.warn('holdIndicator element not found in DOM');
|
||||
}
|
||||
|
||||
// Badge T&C pendiente
|
||||
const termsBadge = document.getElementById('terms-pending-badge');
|
||||
if (termsBadge) {
|
||||
termsBadge.style.display = conv.terms_pending ? 'inline' : 'none';
|
||||
}
|
||||
|
||||
if (releaseBtn) {
|
||||
// show button only when conversation is explicitly 'on_hold' (do NOT show during advisor request)
|
||||
releaseBtn.style.display = (conv.on_hold) ? 'inline-block' : 'none';
|
||||
@@ -3980,6 +4002,7 @@ if (!isset($_SESSION['user_id'])) {
|
||||
<div class="message-actions mt-1">
|
||||
<button class="btn btn-sm btn-link" onclick="chat.promptReply('${window.escapeHtml(msg.message_id || msg.id || '')}')" title="Responder"><i class="fas fa-reply"></i></button>
|
||||
<button class="btn btn-sm btn-link" onclick="chat.openReactionPicker(event, '${window.escapeHtml(msg.message_id || msg.id || '')}')" title="Reaccionar"><i class="far fa-grin"></i></button>
|
||||
${(msg.message_type === 'image' && msg.direction !== 'outgoing') ? `<button class="btn btn-sm btn-link text-primary" data-msg-id="${msg.id||0}" data-local-file="${msg.local_file||msg.local_thumb||''}" data-conv-id="${chat ? chat.currentConversationId : 0}" onclick="labDomicilio.abrir(+this.dataset.msgId, this.dataset.localFile, +this.dataset.convId)" title="Agendar domicilio (con imagen)"><i class="fas fa-house-medical"></i></button>` : ''}${window._labAddrBtn ? window._labAddrBtn(msg, chat ? chat.currentConversationId : 0) : ''}
|
||||
</div>
|
||||
<div class="message-time">${time} ${msg.direction === 'outgoing' ? `<span class="message-status">${statusIcon}</span>` : ''}</div>
|
||||
</div>
|
||||
@@ -6395,5 +6418,920 @@ if (!isset($_SESSION['user_id'])) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════════════
|
||||
MÓDULO LAB — Modal "Agendar Domicilio"
|
||||
════════════════════════════════════════════════════════════════════════ -->
|
||||
<style>
|
||||
#modalAgendarDomicilio .addr-chip {
|
||||
cursor:pointer; border:1px solid #0d6efd; color:#0d6efd;
|
||||
background:#f0f4ff; border-radius:20px; padding:3px 10px;
|
||||
font-size:.78rem; white-space:nowrap; transition:background .15s;
|
||||
}
|
||||
#modalAgendarDomicilio .addr-chip:hover { background:#0d6efd; color:#fff; }
|
||||
#modalAgendarDomicilio .field-err { display:none; font-size:.8rem; color:#dc3545; margin-top:3px; }
|
||||
#modalAgendarDomicilio .is-invalid ~ .field-err,
|
||||
#modalAgendarDomicilio .is-invalid + .field-err { display:block; }
|
||||
</style>
|
||||
|
||||
<div class="modal fade" id="modalAgendarDomicilio" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
|
||||
<div class="modal-header bg-primary text-white py-2">
|
||||
<div>
|
||||
<h5 class="modal-title mb-0"><i class="fas fa-house-medical me-2"></i>Agendar Domicilio</h5>
|
||||
<small id="labdom-header-pac" class="opacity-75"></small>
|
||||
</div>
|
||||
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
|
||||
<!-- ── PANEL ÉXITO (se muestra al guardar) ── -->
|
||||
<div id="labdom-ok-panel" class="d-none">
|
||||
<div class="text-center py-4 px-3">
|
||||
<div class="mb-3" style="font-size:3rem">✅</div>
|
||||
<h5 class="fw-bold text-success" id="labdom-ok-msg">Domicilio agendado</h5>
|
||||
<p class="text-muted mb-4" id="labdom-ok-sub"></p>
|
||||
<div class="d-flex justify-content-center gap-2 flex-wrap">
|
||||
<a id="labdom-ok-asignar" href="lab_domicilios.php" target="_blank" class="btn btn-warning">
|
||||
<i class="fas fa-user-nurse me-1"></i>Asignar enfermera
|
||||
</a>
|
||||
<a id="labdom-ok-ver" href="#" target="_blank" class="btn btn-outline-primary">
|
||||
<i class="fas fa-external-link-alt me-1"></i>Ver domicilio
|
||||
</a>
|
||||
<button type="button" class="btn btn-success" onclick="labDomicilio.agendarOtro()">
|
||||
<i class="fas fa-plus me-1"></i>Agendar otro
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── FORMULARIO ── -->
|
||||
<div id="labdom-form-panel" class="modal-body pb-2">
|
||||
|
||||
<!-- Imagen adjunta -->
|
||||
<div id="labdom-img-prev" class="mb-3 d-flex align-items-center gap-3 p-2 bg-light rounded border" style="display:none!important">
|
||||
<img id="labdom-img-el" src="" class="rounded border" style="max-height:80px;max-width:110px;object-fit:cover">
|
||||
<div>
|
||||
<p class="mb-0 fw-semibold small text-secondary">Orden médica adjunta</p>
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle"><i class="fas fa-image me-1"></i>Foto de la conversación</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TIPO (particular/seguro) — PRIMERO y llamativo ── -->
|
||||
<div class="mb-3 p-2 rounded border bg-light">
|
||||
<label class="fw-semibold small text-secondary mb-2 d-block">
|
||||
<i class="fas fa-id-card me-1"></i>¿ES PARTICULAR O POR SEGURO?
|
||||
</label>
|
||||
<div class="d-flex gap-2">
|
||||
<input type="radio" class="btn-check" name="labdom-tipo-cliente" id="tc-particular" value="particular" autocomplete="off"
|
||||
onchange="labDomicilio._toggleSeguro()">
|
||||
<label class="btn btn-sm btn-outline-secondary" for="tc-particular">
|
||||
<i class="fas fa-wallet me-1"></i>Particular
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="labdom-tipo-cliente" id="tc-seguro" value="seguro" autocomplete="off"
|
||||
onchange="labDomicilio._toggleSeguro()">
|
||||
<label class="btn btn-sm btn-outline-info" for="tc-seguro">
|
||||
<i class="fas fa-shield-alt me-1"></i>Seguro
|
||||
</label>
|
||||
</div>
|
||||
<!-- Campos seguro (visibles solo cuando se elige Seguro) -->
|
||||
<div id="labdom-seguro-row" class="mt-2 d-none">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">¿Qué seguro? <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control form-control-sm" id="labdom-seguro-nombre"
|
||||
placeholder="Ej: Sura, Colsanitas, Compensar, Póliza SOAT…" autocomplete="off">
|
||||
<div class="field-err" id="labdom-seguro-err">Indica el nombre del seguro</div>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small mb-1"><i class="fas fa-hashtag me-1 text-muted"></i>N.° de autorización <span class="text-muted">(opcional)</span></label>
|
||||
<input type="text" class="form-control form-control-sm" id="labdom-autorizacion"
|
||||
placeholder="Ej: 12345678" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small mb-1"><i class="fas fa-hand-holding-usd me-1 text-muted"></i>Copago asume el laboratorio <span class="text-muted">(opcional)</span></label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" min="0" step="1" class="form-control form-control-sm" id="labdom-copago"
|
||||
placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── PACIENTE ── -->
|
||||
<div class="card border-0 bg-light mb-3 p-2">
|
||||
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||
<span class="fw-semibold small text-secondary"><i class="fas fa-user me-1"></i>PACIENTE</span>
|
||||
<button id="labdom-btn-cambiar-pac" class="btn btn-xs btn-link text-primary p-0 small d-none" onclick="labDomicilio.limpiarPaciente()">Cambiar</button>
|
||||
</div>
|
||||
|
||||
<!-- Buscador (visible cuando NO hay paciente seleccionado) -->
|
||||
<div id="labdom-pac-buscar">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="labdom-pac-busq" class="form-control" placeholder="Buscar por nombre o documento…" autocomplete="off">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="labDomicilio.buscarPaciente()"><i class="fas fa-search"></i></button>
|
||||
</div>
|
||||
<div id="labdom-pac-list" class="list-group mt-1 shadow" style="position:relative;z-index:1060;max-height:165px;overflow-y:auto;display:none"></div>
|
||||
<div class="mt-2">
|
||||
<button id="labdom-btn-contacto" class="btn btn-sm btn-outline-success w-100" type="button" onclick="labDomicilio.usarContacto()">
|
||||
<i class="fab fa-whatsapp me-1"></i> Usar contacto de la conversación
|
||||
</button>
|
||||
<div id="labdom-contacto-spin" class="text-center py-1 d-none">
|
||||
<span class="spinner-border spinner-border-sm text-success"></span>
|
||||
<span class="small text-muted ms-1">Buscando contacto…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tarjeta paciente seleccionado (visible cuando SÍ hay paciente) -->
|
||||
<div id="labdom-pac-card" class="d-none">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center flex-shrink-0" style="width:36px;height:36px;font-size:1rem">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 fw-bold" id="labdom-pac-nombre"></p>
|
||||
<small id="labdom-pac-info" class="text-muted"></small>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Datos de contacto del paciente -->
|
||||
<div id="labdom-pac-contacto" class="mt-2 d-none">
|
||||
<div class="row g-1 small">
|
||||
<div class="col-6">
|
||||
<span class="text-muted"><i class="fas fa-phone me-1"></i></span>
|
||||
<span id="labdom-pac-tel" class="fw-semibold">—</span>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<span class="text-muted"><i class="fas fa-envelope me-1"></i></span>
|
||||
<span id="labdom-pac-email" class="fw-semibold">—</span>
|
||||
</div>
|
||||
<div class="col-12" id="labdom-pac-eps-row" style="display:none">
|
||||
<span class="text-muted small"><i class="fas fa-hospital me-1"></i>EPS: </span>
|
||||
<span id="labdom-pac-eps" class="small fw-semibold"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Historial de domicilios previos -->
|
||||
<div id="labdom-hist-cont" class="mt-2 d-none">
|
||||
<p class="mb-1 small text-muted"><i class="fas fa-history me-1"></i>Direcciones usadas anteriormente — toca para reusar:</p>
|
||||
<div id="labdom-hist-chips" class="d-flex flex-wrap gap-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── FECHA · HORA · TIPO ── -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-5">
|
||||
<label class="form-label fw-semibold small mb-1">Fecha <span class="text-danger">*</span></label>
|
||||
<input type="date" class="form-control form-control-sm" id="labdom-fecha">
|
||||
<div class="field-err" id="labdom-fecha-err">Selecciona una fecha</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<label class="form-label small mb-1">Hora</label>
|
||||
<input type="time" class="form-control form-control-sm" id="labdom-hora">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small mb-1">Tipo</label>
|
||||
<select class="form-select form-select-sm" id="labdom-tipo">
|
||||
<option value="domicilio">🏠 Domicilio</option>
|
||||
<option value="urgencia">🚨 Urgencia</option>
|
||||
<option value="control">🔬 Control</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── DIRECCIÓN ── -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small mb-1">Dirección <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control form-control-sm" id="labdom-direccion" placeholder="Ej: Cra 15 # 32-10">
|
||||
<div class="field-err" id="labdom-dir-err">Ingresa la dirección</div>
|
||||
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-6">
|
||||
<input type="text" class="form-control form-control-sm" id="labdom-barrio" placeholder="Barrio">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<input type="text" class="form-control form-control-sm" id="labdom-ciudad" placeholder="Ciudad">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="text" class="form-control form-control-sm" id="labdom-indicaciones" placeholder="Indicaciones: piso, apto, portero, referencias…">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── EXÁMENES SOLICITADOS ── -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small mb-1">
|
||||
<i class="fas fa-flask me-1 text-primary"></i>Exámenes solicitados
|
||||
<span class="badge bg-secondary ms-1" style="font-size:.65rem">si no hay foto de orden</span>
|
||||
</label>
|
||||
<textarea class="form-control form-control-sm" id="labdom-examenes" rows="2"
|
||||
placeholder="Ej: Hemograma completo, glucosa en ayunas, parcial de orina…"></textarea>
|
||||
<div class="form-text text-muted" style="font-size:.73rem">
|
||||
<i class="fas fa-info-circle me-1"></i>Si el cliente envió foto de la orden, este campo es opcional.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── INFORMACIÓN DE COBRO ── -->
|
||||
<div class="mb-3 p-2 rounded border bg-light">
|
||||
<label class="fw-semibold small text-secondary mb-2 d-block">
|
||||
<i class="fas fa-file-invoice-dollar me-1"></i>Información de cobro <span class="fw-normal text-muted">(opcional)</span>
|
||||
</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small mb-1">Valor del domicilio</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" min="0" step="1" class="form-control form-control-sm" id="labdom-valor-domicilio"
|
||||
placeholder="0" oninput="labDomicilio._calcTotal()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small mb-1">Valor del copago</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" min="0" step="1" class="form-control form-control-sm" id="labdom-valor-copago"
|
||||
placeholder="0" oninput="labDomicilio._calcTotal()">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Total a cobrar al cliente -->
|
||||
<div class="col-12" id="labdom-total-row" style="display:none">
|
||||
<div class="d-flex align-items-center justify-content-between rounded px-3 py-2 mt-1"
|
||||
style="background:#d1fae5;border:1px solid #6ee7b7">
|
||||
<span class="fw-semibold small text-success">
|
||||
<i class="fas fa-cash-register me-1"></i>Total a cobrar al cliente
|
||||
</span>
|
||||
<span class="fw-bold text-success" id="labdom-total-valor" style="font-size:1rem">$0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── NOTAS ADMIN ── -->
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1"><i class="fas fa-sticky-note me-1"></i>Notas internas del agendamiento</label>
|
||||
<textarea class="form-control form-control-sm" id="labdom-notas" rows="2"
|
||||
placeholder="Indicaciones especiales, observaciones del operador…"></textarea>
|
||||
</div>
|
||||
|
||||
</div><!-- /form-panel -->
|
||||
|
||||
<div id="labdom-footer" class="modal-footer py-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="labdom-btn-guardar" onclick="labDomicilio.guardar()">
|
||||
<i class="fas fa-calendar-check me-1"></i> Agendar Domicilio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Módulo Lab — Agendar Domicilio desde conversations.php
|
||||
// Soporta múltiples domicilios al mismo paciente en la misma sesión.
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
const labDomicilio = (() => {
|
||||
// Estado interno
|
||||
let _msgId = null;
|
||||
let _localFile = null;
|
||||
let _convId = null;
|
||||
let _pacienteId = null;
|
||||
let _pacNombre = '';
|
||||
let _histDirs = []; // direcciones únicas previas del paciente
|
||||
let _bsModal = null;
|
||||
|
||||
// ── helpers DOM ──────────────────────────────────────────────────────────
|
||||
const $ = id => document.getElementById(id);
|
||||
const fv = id => $( id).value.trim();
|
||||
|
||||
function fechaManana() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function mostrarFormulario() {
|
||||
$('labdom-ok-panel').classList.add('d-none');
|
||||
$('labdom-form-panel').style.display = '';
|
||||
$('labdom-footer').style.display = '';
|
||||
}
|
||||
|
||||
function limpiarValidacion() {
|
||||
['labdom-fecha','labdom-direccion'].forEach(id => $(id).classList.remove('is-invalid'));
|
||||
}
|
||||
|
||||
function validar() {
|
||||
let ok = true;
|
||||
limpiarValidacion();
|
||||
if (!fv('labdom-fecha')) { $('labdom-fecha').classList.add('is-invalid'); ok = false; }
|
||||
if (!fv('labdom-direccion')){ $('labdom-direccion').classList.add('is-invalid'); ok = false; }
|
||||
const esSeguro = document.getElementById('tc-seguro')?.checked;
|
||||
if (esSeguro && !fv('labdom-seguro-nombre')) {
|
||||
$('labdom-seguro-nombre').classList.add('is-invalid');
|
||||
ok = false;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ── cargar historial de direcciones del paciente ─────────────────────────
|
||||
async function cargarHistorial(pacienteId) {
|
||||
$('labdom-hist-cont').classList.add('d-none');
|
||||
$('labdom-hist-chips').innerHTML = '';
|
||||
_histDirs = [];
|
||||
try {
|
||||
const r = await fetch(`api/lab/get_domicilios.php?paciente_id=${pacienteId}&limit=30&no_stats=1`);
|
||||
const d = await r.json();
|
||||
if (!d.data?.length) return;
|
||||
|
||||
// Deduplicar por dirección+barrio, mantener las más recientes primero
|
||||
const vistas = new Set();
|
||||
const dirs = [];
|
||||
for (const dom of d.data) {
|
||||
const key = (dom.direccion||'').toLowerCase().trim();
|
||||
if (!key || vistas.has(key)) continue;
|
||||
vistas.add(key);
|
||||
dirs.push({
|
||||
direccion: dom.direccion || '',
|
||||
barrio: dom.barrio || '',
|
||||
ciudad: dom.ciudad || '',
|
||||
indicaciones_dir: dom.indicaciones_dir || '',
|
||||
});
|
||||
if (dirs.length >= 4) break; // máximo 4 chips
|
||||
}
|
||||
if (!dirs.length) return;
|
||||
|
||||
_histDirs = dirs;
|
||||
const chips = $('labdom-hist-chips');
|
||||
dirs.forEach((dir, i) => {
|
||||
const label = dir.barrio ? `${dir.direccion} (${dir.barrio})` : dir.direccion;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'addr-chip';
|
||||
btn.title = [dir.direccion, dir.barrio, dir.ciudad].filter(Boolean).join(' · ');
|
||||
btn.innerHTML = `<i class="fas fa-map-marker-alt me-1"></i>${escLab(label)}`;
|
||||
btn.onclick = () => labDomicilio.usarDireccion(i);
|
||||
chips.appendChild(btn);
|
||||
});
|
||||
$('labdom-hist-cont').classList.remove('d-none');
|
||||
} catch (_) { /* silencioso */ }
|
||||
}
|
||||
|
||||
// ── API pública ──────────────────────────────────────────────────────────
|
||||
return {
|
||||
|
||||
/* Abre el modal desde el botón del mensaje imagen */
|
||||
abrir(msgId, localFile, convId) {
|
||||
_msgId = msgId || null;
|
||||
_localFile = localFile|| null;
|
||||
_convId = convId ? parseInt(convId) : null;
|
||||
// Guardar también el userId actual para usarContacto cuando convId no esté disponible
|
||||
if (!_convId && typeof chatApp !== 'undefined' && chatApp.currentUserId) {
|
||||
_convId = '__user:' + chatApp.currentUserId;
|
||||
}
|
||||
|
||||
mostrarFormulario();
|
||||
labDomicilio._resetFull();
|
||||
|
||||
if (_localFile) {
|
||||
$('labdom-img-el').src = `uploads/media/${_localFile}`;
|
||||
$('labdom-img-prev').style.cssText = ''; // quita display:none!important
|
||||
} else {
|
||||
$('labdom-img-prev').style.display = 'none';
|
||||
}
|
||||
|
||||
if (!_bsModal) _bsModal = new bootstrap.Modal('#modalAgendarDomicilio');
|
||||
_bsModal.show();
|
||||
|
||||
// Pre-cargar contacto de la conversación de forma silenciosa
|
||||
if (_convId) labDomicilio.usarContacto();
|
||||
},
|
||||
|
||||
/* Reset completo (nombre paciente + todos los campos) */
|
||||
_resetFull() {
|
||||
_pacienteId = null;
|
||||
_pacNombre = '';
|
||||
_histDirs = [];
|
||||
$('labdom-pac-busq').value = '';
|
||||
$('labdom-pac-list').style.display = 'none';
|
||||
$('labdom-pac-buscar').classList.remove('d-none');
|
||||
$('labdom-pac-card').classList.add('d-none');
|
||||
$('labdom-btn-cambiar-pac').classList.add('d-none');
|
||||
$('labdom-pac-contacto').classList.add('d-none');
|
||||
$('labdom-hist-cont').classList.add('d-none');
|
||||
$('labdom-hist-chips').innerHTML = '';
|
||||
$('labdom-header-pac').textContent = '';
|
||||
// Resetear tipo cliente a "particular" por defecto
|
||||
const tc = document.getElementById('tc-particular');
|
||||
if (tc) tc.checked = true;
|
||||
labDomicilio._toggleSeguro();
|
||||
labDomicilio._resetDomicilio();
|
||||
},
|
||||
|
||||
/* Muestra/oculta el campo «nombre del seguro» según la selección */
|
||||
_toggleSeguro() {
|
||||
const esSeguro = document.getElementById('tc-seguro')?.checked;
|
||||
const row = $('labdom-seguro-row');
|
||||
if (!row) return;
|
||||
if (esSeguro) {
|
||||
row.classList.remove('d-none');
|
||||
} else {
|
||||
row.classList.add('d-none');
|
||||
if ($('labdom-seguro-nombre')) $('labdom-seguro-nombre').value = '';
|
||||
}
|
||||
},
|
||||
|
||||
/* Calcula y muestra el total a cobrar al cliente */
|
||||
_calcTotal() {
|
||||
const dom = parseFloat($('labdom-valor-domicilio')?.value) || 0;
|
||||
const copago = parseFloat($('labdom-valor-copago')?.value) || 0;
|
||||
const total = dom + copago;
|
||||
const row = $('labdom-total-row');
|
||||
const label = $('labdom-total-valor');
|
||||
if (!row || !label) return;
|
||||
if (total > 0) {
|
||||
label.textContent = '$' + total.toLocaleString('es-CO');
|
||||
row.style.display = '';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
/* Reset solo los campos del domicilio (mantiene paciente) */
|
||||
_resetDomicilio() {
|
||||
$('labdom-fecha').value = fechaManana();
|
||||
$('labdom-hora').value = '07:00';
|
||||
$('labdom-tipo').value = 'domicilio';
|
||||
$('labdom-direccion').value = '';
|
||||
$('labdom-barrio').value = '';
|
||||
$('labdom-ciudad').value = '';
|
||||
$('labdom-indicaciones').value= '';
|
||||
$('labdom-examenes').value = '';
|
||||
$('labdom-notas').value = '';
|
||||
if ($('labdom-seguro-nombre')) $('labdom-seguro-nombre').value = '';
|
||||
if ($('labdom-autorizacion')) $('labdom-autorizacion').value = '';
|
||||
if ($('labdom-copago')) $('labdom-copago').value = '';
|
||||
if ($('labdom-valor-domicilio')) $('labdom-valor-domicilio').value = '';
|
||||
if ($('labdom-valor-copago')) $('labdom-valor-copago').value = '';
|
||||
labDomicilio._calcTotal();
|
||||
limpiarValidacion();
|
||||
},
|
||||
|
||||
/* ── Buscar paciente ── */
|
||||
async buscarPaciente() {
|
||||
const q = fv('labdom-pac-busq');
|
||||
if (!q) return;
|
||||
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(q)}&limit=8`);
|
||||
const d = await r.json();
|
||||
const list = $('labdom-pac-list');
|
||||
if (!d.data?.length) {
|
||||
list.innerHTML = '<a class="list-group-item list-group-item-action text-muted disabled small py-2">Sin resultados</a>';
|
||||
list.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = d.data.map(p =>
|
||||
`<button type="button" class="list-group-item list-group-item-action py-2"
|
||||
data-pid="${p.id}"
|
||||
data-nombre="${escAtr(p.nombre_completo)}"
|
||||
data-dir="${escAtr(p.direccion||'')}"
|
||||
data-ciudad="${escAtr(p.ciudad||'')}"
|
||||
data-info="${escAtr((p.tipo_documento||'') + ' ' + (p.numero_documento||''))}"
|
||||
data-tel="${escAtr(p.telefono||'')}"
|
||||
data-email="${escAtr(p.email||'')}"
|
||||
data-eps="${escAtr(p.eps||'')}"
|
||||
onclick="labDomicilio._clickPaciente(this)">
|
||||
<strong class="small">${escLab(p.nombre_completo)}</strong>
|
||||
<span class="text-muted ms-2 small">${escLab(p.tipo_documento||'')} ${escLab(p.numero_documento||'')}</span>
|
||||
${p.telefono ? `<span class="ms-2 small text-success"><i class="fas fa-phone me-1"></i>${escLab(p.telefono)}</span>` : ''}
|
||||
${p.direccion ? `<br><small class="text-primary"><i class="fas fa-map-marker-alt me-1"></i>${escLab(p.direccion)}</small>` : ''}
|
||||
</button>`
|
||||
).join('');
|
||||
list.style.display = 'block';
|
||||
},
|
||||
|
||||
_clickPaciente(btn) {
|
||||
labDomicilio.selPaciente(
|
||||
+btn.dataset.pid,
|
||||
btn.dataset.nombre,
|
||||
btn.dataset.dir,
|
||||
btn.dataset.ciudad,
|
||||
btn.dataset.info,
|
||||
btn.dataset.tel || '',
|
||||
btn.dataset.email || '',
|
||||
btn.dataset.eps || ''
|
||||
);
|
||||
},
|
||||
|
||||
selPaciente(id, nombre, direccion, ciudad, info, tel = '', email = '', eps = '') {
|
||||
_pacienteId = id;
|
||||
_pacNombre = nombre;
|
||||
|
||||
// Ocultar buscador, mostrar tarjeta
|
||||
$('labdom-pac-list').style.display = 'none';
|
||||
$('labdom-pac-buscar').classList.add('d-none');
|
||||
$('labdom-pac-card').classList.remove('d-none');
|
||||
$('labdom-btn-cambiar-pac').classList.remove('d-none');
|
||||
$('labdom-pac-nombre').textContent = nombre;
|
||||
$('labdom-pac-info').textContent = info || '';
|
||||
$('labdom-header-pac').textContent = nombre;
|
||||
|
||||
// Mostrar datos de contacto
|
||||
if (tel || email) {
|
||||
$('labdom-pac-tel').textContent = tel || '—';
|
||||
$('labdom-pac-email').textContent = email || '—';
|
||||
if (eps) {
|
||||
$('labdom-pac-eps').textContent = eps;
|
||||
$('labdom-pac-eps-row').style.display = '';
|
||||
} else {
|
||||
$('labdom-pac-eps-row').style.display = 'none';
|
||||
}
|
||||
$('labdom-pac-contacto').classList.remove('d-none');
|
||||
} else {
|
||||
$('labdom-pac-contacto').classList.add('d-none');
|
||||
}
|
||||
|
||||
// Pre-llenar campos solo si están vacíos
|
||||
if (direccion && !fv('labdom-direccion')) $('labdom-direccion').value = direccion;
|
||||
if (ciudad && !fv('labdom-ciudad')) $('labdom-ciudad').value = ciudad;
|
||||
|
||||
// Cargar historial de domicilios previos
|
||||
cargarHistorial(id);
|
||||
},
|
||||
|
||||
limpiarPaciente() {
|
||||
_pacienteId = null;
|
||||
_pacNombre = '';
|
||||
$('labdom-pac-buscar').classList.remove('d-none');
|
||||
$('labdom-pac-card').classList.add('d-none');
|
||||
$('labdom-btn-cambiar-pac').classList.add('d-none');
|
||||
$('labdom-pac-busq').value = '';
|
||||
$('labdom-pac-contacto').classList.add('d-none');
|
||||
$('labdom-hist-cont').classList.add('d-none');
|
||||
$('labdom-header-pac').textContent = '';
|
||||
// Mantener dirección/notas para no perder lo escrito
|
||||
},
|
||||
|
||||
/* Aplica una dirección histórica al formulario */
|
||||
usarDireccion(idx) {
|
||||
const dir = _histDirs[idx];
|
||||
if (!dir) return;
|
||||
$('labdom-direccion').value = dir.direccion;
|
||||
$('labdom-barrio').value = dir.barrio;
|
||||
$('labdom-ciudad').value = dir.ciudad;
|
||||
$('labdom-indicaciones').value = dir.indicaciones_dir;
|
||||
limpiarValidacion();
|
||||
// Resaltar brevemente el campo
|
||||
$('labdom-direccion').classList.add('border-primary');
|
||||
setTimeout(() => $('labdom-direccion').classList.remove('border-primary'), 1200);
|
||||
},
|
||||
|
||||
/* Contacto de WhatsApp → paciente automático */
|
||||
async usarContacto() {
|
||||
// Obtener convId o userId del contexto actual
|
||||
let effectiveConvId = _convId;
|
||||
if (!effectiveConvId && typeof chatApp !== 'undefined' && chatApp.currentUserId) {
|
||||
effectiveConvId = '__user:' + chatApp.currentUserId;
|
||||
}
|
||||
if (!effectiveConvId) return;
|
||||
|
||||
const spin = $('labdom-contacto-spin');
|
||||
const btn = $('labdom-btn-contacto');
|
||||
try {
|
||||
spin.classList.remove('d-none');
|
||||
btn.classList.add('d-none');
|
||||
// Determinar si es user_id o conversation_id
|
||||
let qparam;
|
||||
if (String(effectiveConvId).startsWith('__user:')) {
|
||||
qparam = `user_id=${String(effectiveConvId).slice(7)}`;
|
||||
} else {
|
||||
qparam = `conversation_id=${effectiveConvId}`;
|
||||
}
|
||||
const r = await fetch(`api/lab/crear_desde_whatsapp.php?solo_paciente=1&${qparam}`);
|
||||
const d = await r.json();
|
||||
if (d.success && d.paciente) {
|
||||
labDomicilio.selPaciente(
|
||||
d.paciente.id,
|
||||
d.paciente.nombre_completo,
|
||||
d.paciente.direccion || '',
|
||||
d.paciente.ciudad || '',
|
||||
[(d.paciente.tipo_documento||''), (d.paciente.numero_documento||'')].filter(Boolean).join(' '),
|
||||
d.paciente.telefono || '',
|
||||
d.paciente.email || '',
|
||||
d.paciente.eps || ''
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (_) { /* silencioso */ } finally {
|
||||
spin.classList.add('d-none');
|
||||
if (!_pacienteId) btn.classList.remove('d-none');
|
||||
}
|
||||
},
|
||||
|
||||
/* ── Guardar domicilio ── */
|
||||
async guardar() {
|
||||
if (!_pacienteId) {
|
||||
$('labdom-pac-busq').focus();
|
||||
$('labdom-pac-busq').classList.add('is-invalid');
|
||||
setTimeout(() => $('labdom-pac-busq').classList.remove('is-invalid'), 2000);
|
||||
return;
|
||||
}
|
||||
if (!validar()) return;
|
||||
|
||||
const btn = $('labdom-btn-guardar');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Guardando…';
|
||||
|
||||
const datos = {
|
||||
paciente_id: _pacienteId,
|
||||
fecha_programada: fv('labdom-fecha'),
|
||||
hora_programada: fv('labdom-hora') || null,
|
||||
tipo_servicio: fv('labdom-tipo'),
|
||||
tipo_cliente: (document.querySelector('input[name="labdom-tipo-cliente"]:checked')?.value) || 'particular',
|
||||
seguro_nombre: (document.querySelector('input[name="labdom-tipo-cliente"]:checked')?.value === 'seguro') ? (fv('labdom-seguro-nombre') || null) : null,
|
||||
autorizacion: (document.querySelector('input[name="labdom-tipo-cliente"]:checked')?.value === 'seguro') ? (fv('labdom-autorizacion') || null) : null,
|
||||
copago_laboratorio: (document.querySelector('input[name="labdom-tipo-cliente"]:checked')?.value === 'seguro') ? ($('labdom-copago').value || null) : null,
|
||||
valor_domicilio: $('labdom-valor-domicilio').value || null,
|
||||
valor_copago: $('labdom-valor-copago').value || null,
|
||||
examenes_solicitados: fv('labdom-examenes') || null,
|
||||
direccion: fv('labdom-direccion'),
|
||||
barrio: fv('labdom-barrio') || null,
|
||||
ciudad: fv('labdom-ciudad') || null,
|
||||
indicaciones_dir: fv('labdom-indicaciones') || null,
|
||||
notas_admin: fv('labdom-notas') || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch('api/lab/save_domicilio.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(datos),
|
||||
});
|
||||
const d = await r.json();
|
||||
|
||||
if (d.success) {
|
||||
// Mostrar panel de éxito DENTRO del modal (no cierra)
|
||||
$('labdom-form-panel').style.display = 'none';
|
||||
$('labdom-footer').style.display = 'none';
|
||||
const horaStr = datos.hora_programada ? ` a las ${datos.hora_programada}` : '';
|
||||
$('labdom-ok-msg').textContent = `✅ Domicilio #${d.id} agendado`;
|
||||
const tipoLabel = { particular:'👤 Particular', seguro:'🛡️ Seguro' };
|
||||
if (datos.tipo_cliente === 'seguro' && datos.seguro_nombre) tipoLabel.seguro = `🛡️ Seguro — ${datos.seguro_nombre}`;
|
||||
const examStr = datos.examenes_solicitados ? `<br><small class="text-muted">${escLab(datos.examenes_solicitados)}</small>` : '';
|
||||
$('labdom-ok-sub').innerHTML =
|
||||
`<strong>${escLab(_pacNombre)}</strong> · <span class="badge bg-secondary">${tipoLabel[datos.tipo_cliente]||datos.tipo_cliente}</span><br>${escLab(datos.direccion)}${examStr}<br><span class="badge bg-primary">${datos.fecha_programada}${horaStr}</span>`;
|
||||
$('labdom-ok-ver').href = `lab_domicilios.php?id=${d.id}`;
|
||||
$('labdom-ok-asignar').href = `lab_domicilios.php?id=${d.id}`;
|
||||
$('labdom-ok-panel').classList.remove('d-none');
|
||||
|
||||
// Actualizar historial (puede haber nueva dirección)
|
||||
if (_pacienteId) cargarHistorial(_pacienteId);
|
||||
} else {
|
||||
// Error inline
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'alert alert-danger alert-dismissible mt-2 py-2';
|
||||
errDiv.innerHTML = `<i class="fas fa-exclamation-circle me-1"></i>${escLab(d.error||'Error al guardar')}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||||
$('labdom-form-panel').prepend(errDiv);
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i> Agendar Domicilio';
|
||||
}
|
||||
},
|
||||
|
||||
/* "Agendar otro" → mantiene paciente, resetea solo el domicilio */
|
||||
agendarOtro() {
|
||||
mostrarFormulario();
|
||||
labDomicilio._resetDomicilio();
|
||||
// Si hay historial, enfocar notas (la dirección ya tiene chips para elegir)
|
||||
if (_histDirs.length) {
|
||||
$('labdom-notas').focus();
|
||||
} else {
|
||||
$('labdom-direccion').focus();
|
||||
}
|
||||
},
|
||||
|
||||
/* Cierra el modal de citas si está visible y llama cb() al terminar */
|
||||
_cerrarCitas(cb) {
|
||||
const elC = document.getElementById('modalCitasPaciente');
|
||||
if (elC && elC.classList.contains('show')) {
|
||||
const inst = bootstrap.Modal.getInstance(elC);
|
||||
if (inst) {
|
||||
elC.addEventListener('hidden.bs.modal', cb, { once: true });
|
||||
inst.hide();
|
||||
return;
|
||||
}
|
||||
}
|
||||
cb();
|
||||
},
|
||||
|
||||
/* Abrir modal sin imagen (botón header o "nueva cita" desde citasModal) */
|
||||
abrirRapido() {
|
||||
// currentConversationId no siempre está disponible; usamos currentUserId como fallback
|
||||
const convId = (typeof chatApp !== 'undefined' && chatApp.currentConversationId)
|
||||
? chatApp.currentConversationId : 0;
|
||||
this._cerrarCitas(() => labDomicilio.abrir(0, '', convId));
|
||||
},
|
||||
|
||||
/* Abrir modal y pre-rellenar dirección detectada en un mensaje */
|
||||
abrirConTexto(convId, address) {
|
||||
this._cerrarCitas(() => {
|
||||
labDomicilio.abrir(0, '', convId);
|
||||
setTimeout(() => {
|
||||
const inp = document.getElementById('labdom-direccion');
|
||||
if (inp && address) { inp.value = address; inp.dispatchEvent(new Event('input')); }
|
||||
}, 140);
|
||||
});
|
||||
},
|
||||
|
||||
/* Usar una dirección del historial de citas (desde citasModal) */
|
||||
_usarDirHistorial(dir) {
|
||||
this._cerrarCitas(() => {
|
||||
const inp = document.getElementById('labdom-direccion');
|
||||
if (inp) { inp.value = dir; inp.dispatchEvent(new Event('input')); }
|
||||
const el = document.getElementById('modalAgendarDomicilio');
|
||||
if (el) bootstrap.Modal.getOrCreateInstance(el).show();
|
||||
});
|
||||
},
|
||||
|
||||
/* Devuelve el ID del paciente actualmente seleccionado */
|
||||
getPacienteId() { return _pacienteId || 0; },
|
||||
getPacienteNombre() {
|
||||
const el = document.getElementById('labdom-pac-nombre');
|
||||
return el ? el.textContent.trim() : '';
|
||||
},
|
||||
|
||||
};
|
||||
})();
|
||||
|
||||
// Enter en el buscador de paciente
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target.id === 'labdom-pac-busq' && e.key === 'Enter') labDomicilio.buscarPaciente();
|
||||
});
|
||||
// Cerrar dropdown de paciente al hacer clic afuera
|
||||
document.addEventListener('click', e => {
|
||||
const list = document.getElementById('labdom-pac-list');
|
||||
if (list && !list.contains(e.target) && e.target.id !== 'labdom-pac-busq') {
|
||||
list.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
const escLab = s => String(s||'').replace(/[<>&"'`]/g, c =>({'<':'<','>':'>','&':'&','"':'"',"'":''','`':'`'}[c]));
|
||||
const escAtr = s => String(s||'').replace(/"/g, '"');
|
||||
|
||||
/* ─── Detección de direcciones colombianas en mensajes ─── */
|
||||
window._detectAddr = function(text) {
|
||||
if (!text) return null;
|
||||
const re = /(calle|cl\.?|carrera|cra\.?|kr\.?|avenida|av\.?|diagonal|dg\.?|transversal|tv\.?|manzana|mz\.?)\s*[\d\w#\-\.]+[\s,][\s\d\w#\-\.°barrioaptolocalnorte sur este oeste]{4,60}/gi;
|
||||
const m = String(text).match(re);
|
||||
return (m && m.length) ? m[0].trim() : null;
|
||||
};
|
||||
|
||||
window._labAddrBtn = function(msg, convId) {
|
||||
if (msg.direction === 'outgoing') return '';
|
||||
const addr = window._detectAddr(msg.body || msg.message || '');
|
||||
if (!addr) return '';
|
||||
const safeAddr = addr.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
return `<button class="btn btn-sm btn-link text-success" title="Agendar domicilio en esta dirección"
|
||||
onclick="labDomicilio.abrirConTexto(${convId}, '${safeAddr}')"
|
||||
><i class="fas fa-map-marker-alt"></i> Agendar</button>`;
|
||||
};
|
||||
|
||||
/* ─── Modal "Ver citas del paciente" ─── */
|
||||
const citasModal = (() => {
|
||||
let _pacId = 0;
|
||||
|
||||
function abrir() {
|
||||
/* Obtener paciente del contexto actual de la conversación */
|
||||
const convId = (typeof chatApp !== 'undefined' && chatApp.currentConversationId)
|
||||
? chatApp.currentConversationId : 0;
|
||||
|
||||
const el = document.getElementById('modalCitasPaciente');
|
||||
if (!el) return;
|
||||
const bsM = bootstrap.Modal.getOrCreateInstance(el);
|
||||
bsM.show();
|
||||
|
||||
/* Usar el paciente activo en labDomicilio si existe */
|
||||
_pacId = (typeof labDomicilio !== 'undefined' && labDomicilio.getPacienteId)
|
||||
? labDomicilio.getPacienteId() : 0;
|
||||
if (_pacId) {
|
||||
const nom = labDomicilio.getPacienteNombre ? labDomicilio.getPacienteNombre() : '';
|
||||
const h = document.getElementById('citas-pac-nombre');
|
||||
if (h && nom) h.textContent = nom;
|
||||
cargar(_pacId);
|
||||
} else {
|
||||
/* Si no hay paciente seleccionado, obtenerlo desde la conversación o usuario activo */
|
||||
const userId = typeof chatApp !== 'undefined' ? (chatApp.currentUserId || 0) : 0;
|
||||
const qparam = convId ? `conversation_id=${convId}` : (userId ? `user_id=${userId}` : '');
|
||||
if (!qparam) { mostrarVacio('Abre una conversación primero.'); return; }
|
||||
fetch(`api/lab/crear_desde_whatsapp.php?solo_paciente=1&${qparam}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.success && d.paciente && d.paciente.id) {
|
||||
_pacId = d.paciente.id;
|
||||
document.getElementById('citas-pac-nombre').textContent = d.paciente.nombre_completo || '';
|
||||
cargar(_pacId);
|
||||
} else {
|
||||
mostrarVacio('No se encontró paciente asociado a esta conversación.');
|
||||
}
|
||||
})
|
||||
.catch(() => mostrarVacio('Error al obtener datos del paciente.'));
|
||||
}
|
||||
}
|
||||
|
||||
function cargar(pacId) {
|
||||
const tbody = document.getElementById('citas-tbody');
|
||||
const header = document.getElementById('citas-pac-nombre');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="text-center py-3"><div class="spinner-border spinner-border-sm text-primary" role="status"></div> Cargando…</td></tr>`;
|
||||
|
||||
fetch(`api/lab/get_domicilios.php?paciente_id=${pacId}&limit=20`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const rows = (data.data || data.domicilios || data || []);
|
||||
if (!rows.length) { mostrarVacio('Este paciente no tiene citas registradas.'); return; }
|
||||
if (rows[0] && rows[0].paciente_nombre && header) header.textContent = rows[0].paciente_nombre;
|
||||
tbody.innerHTML = rows.map(c => {
|
||||
const estado = c.estado || 'pendiente';
|
||||
const colorMap = {pendiente:'warning',confirmado:'info',en_camino:'primary',completado:'success',cancelado:'danger'};
|
||||
const badge = `<span class="badge bg-${colorMap[estado]||'secondary'}">${estado}</span>`;
|
||||
const tipo = c.tipo_cliente ? `<span class="badge bg-light text-dark border">${c.tipo_cliente}</span>` : '';
|
||||
const dir = escLab(c.direccion || '');
|
||||
const fecha = c.fecha_programada ? c.fecha_programada.substring(0,16).replace('T',' ') : (c.fecha || '—');
|
||||
return `<tr>
|
||||
<td class="text-nowrap small">${escLab(fecha)}</td>
|
||||
<td class="small">${dir}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${tipo}</td>
|
||||
<td class="text-nowrap">
|
||||
<button class="btn btn-xs btn-outline-success py-0 px-2 small" title="Agendar con esta dirección"
|
||||
onclick="labDomicilio._usarDirHistorial('${dir.replace(/'/g,"\\'")}')">
|
||||
<i class="fas fa-location-arrow me-1"></i>Usar
|
||||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
})
|
||||
.catch(() => mostrarVacio('Error al cargar las citas.'));
|
||||
}
|
||||
|
||||
function recargar() { if (_pacId) cargar(_pacId); }
|
||||
|
||||
function mostrarVacio(msg) {
|
||||
const tbody = document.getElementById('citas-tbody');
|
||||
if (tbody) tbody.innerHTML = `<tr><td colspan="5" class="text-center text-muted py-3">${escLab(msg)}</td></tr>`;
|
||||
}
|
||||
|
||||
return { abrir, recargar };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Modal: Citas del paciente -->
|
||||
<div class="modal fade" id="modalCitasPaciente" tabindex="-1" aria-labelledby="modalCitasPacienteTitulo" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-warning bg-opacity-10">
|
||||
<h5 class="modal-title" id="modalCitasPacienteTitulo">
|
||||
<i class="fas fa-calendar-alt me-2 text-warning"></i>
|
||||
Citas — <span id="citas-pac-nombre" class="text-primary">paciente</span>
|
||||
</h5>
|
||||
<div class="ms-auto d-flex gap-2 me-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="citasModal.recargar()" title="Recargar">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-success" onclick="labDomicilio.abrirRapido()" title="Nueva cita">
|
||||
<i class="fas fa-plus"></i> Nueva
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Fecha / Hora</th>
|
||||
<th>Dirección</th>
|
||||
<th>Estado</th>
|
||||
<th>Tipo</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="citas-tbody">
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">–</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
-- ============================================================
|
||||
-- Migration: 04_add_domicilio_cobro_fields
|
||||
-- Agrega campos de seguro, autorización y cobro a lab_domicilios
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `lab_domicilios`
|
||||
ADD COLUMN IF NOT EXISTS `seguro_nombre` VARCHAR(150) DEFAULT NULL
|
||||
COMMENT 'Nombre del seguro cuando tipo_cliente = seguro'
|
||||
AFTER `tipo_cliente`,
|
||||
|
||||
ADD COLUMN IF NOT EXISTS `autorizacion` VARCHAR(100) DEFAULT NULL
|
||||
COMMENT 'Número de autorización del seguro'
|
||||
AFTER `seguro_nombre`,
|
||||
|
||||
ADD COLUMN IF NOT EXISTS `copago_laboratorio` DECIMAL(12,2) DEFAULT NULL
|
||||
COMMENT 'Copago que asume el laboratorio'
|
||||
AFTER `autorizacion`,
|
||||
|
||||
ADD COLUMN IF NOT EXISTS `valor_domicilio` DECIMAL(12,2) DEFAULT NULL
|
||||
COMMENT 'Valor a cobrar al cliente por el domicilio'
|
||||
AFTER `copago_laboratorio`,
|
||||
|
||||
ADD COLUMN IF NOT EXISTS `valor_copago` DECIMAL(12,2) DEFAULT NULL
|
||||
COMMENT 'Valor del copago a cobrar al cliente'
|
||||
AFTER `valor_domicilio`;
|
||||
|
||||
-- Actualizar ENUM tipo_cliente para quitar eps y dejar solo particular/seguro
|
||||
ALTER TABLE `lab_domicilios`
|
||||
MODIFY COLUMN `tipo_cliente` ENUM('particular','seguro') NOT NULL DEFAULT 'particular'
|
||||
COMMENT 'Si el servicio es particular o por seguro';
|
||||
@@ -0,0 +1,49 @@
|
||||
-- ============================================================
|
||||
-- Migration: 05_terms_acceptance
|
||||
-- Sistema de aceptación de términos y condiciones
|
||||
-- ============================================================
|
||||
|
||||
-- Tabla de versiones de términos (cada vez que cambia el documento)
|
||||
CREATE TABLE IF NOT EXISTS `terms_versions` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`version` VARCHAR(50) NOT NULL COMMENT 'Ej: 1.0, 2.0, 2026-03',
|
||||
`documento_url` VARCHAR(500) DEFAULT NULL COMMENT 'URL pública del documento PDF',
|
||||
`documento_nombre` VARCHAR(255) DEFAULT NULL COMMENT 'Nombre original del archivo',
|
||||
`mensaje_aceptacion` TEXT NOT NULL COMMENT 'Mensaje que se envía al usuario para aceptar',
|
||||
`mensaje_rechazo` TEXT NOT NULL COMMENT 'Mensaje que se envía si el usuario rechaza',
|
||||
`forzar_reenvio` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = reenviar a todos los usuarios aunque ya aceptaron',
|
||||
`activa` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1 = es la versión vigente',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_activa` (`activa`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Tabla de aceptaciones de usuarios
|
||||
CREATE TABLE IF NOT EXISTS `terms_acceptance` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT(11) NOT NULL COMMENT 'FK a users.id',
|
||||
`terms_version_id` INT(11) NOT NULL COMMENT 'FK a terms_versions.id',
|
||||
`phone_number` VARCHAR(20) NOT NULL COMMENT 'Copia desnormalizada para auditoría',
|
||||
`estado` ENUM('pendiente','aceptado','rechazado') NOT NULL DEFAULT 'pendiente',
|
||||
`fecha_envio` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Cuando se envió el mensaje de términos',
|
||||
`fecha_respuesta` TIMESTAMP NULL DEFAULT NULL COMMENT 'Cuando el usuario respondió',
|
||||
`ip_referencia` VARCHAR(45) DEFAULT NULL COMMENT 'IP del webhook si disponible',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_version` (`user_id`, `terms_version_id`),
|
||||
KEY `idx_estado` (`estado`),
|
||||
KEY `idx_phone` (`phone_number`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Columna en users para saber si está esperando respuesta de términos
|
||||
ALTER TABLE `users`
|
||||
ADD COLUMN IF NOT EXISTS `terms_pending` TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT '1 = se enviaron términos y se espera respuesta'
|
||||
AFTER `welcome_sent_at`,
|
||||
ADD COLUMN IF NOT EXISTS `terms_accepted_at` TIMESTAMP NULL DEFAULT NULL
|
||||
COMMENT 'Última vez que aceptó los términos vigentes'
|
||||
AFTER `terms_pending`,
|
||||
ADD COLUMN IF NOT EXISTS `terms_version_id` INT(11) NULL DEFAULT NULL
|
||||
COMMENT 'Versión de términos que aceptó'
|
||||
AFTER `terms_accepted_at`;
|
||||
@@ -0,0 +1,204 @@
|
||||
-- =============================================================================
|
||||
-- 06_master_sync.sql
|
||||
-- Migración maestra de sincronización local ↔ producción
|
||||
-- Generada: 2025-01-01
|
||||
--
|
||||
-- INSTRUCCIONES DE APLICACIÓN:
|
||||
-- LOCAL (Docker MySQL):
|
||||
-- docker exec whatsapp-dev-mysql mysql -uroot -proot_password_2026 \
|
||||
-- usite_whatsapp_bot < database/06_master_sync.sql
|
||||
--
|
||||
-- PRODUCCIÓN (desde el contenedor app):
|
||||
-- - Aplica SOLO las secciones marcadas [SOLO PROD] si prod ya las tiene,
|
||||
-- o la migración completa en instalaciones nuevas.
|
||||
--
|
||||
-- GARANTÍA DE SEGURIDAD:
|
||||
-- Este script es COMPLETAMENTE NO DESTRUCTIVO. No contiene DROP TABLE,
|
||||
-- TRUNCATE, DELETE ni UPDATE. Todas las operaciones usan IF NOT EXISTS o
|
||||
-- INSERT IGNORE, por lo que correrlo en un servidor con datos existentes
|
||||
-- solo creará lo que falte sin modificar ni eliminar nada.
|
||||
--
|
||||
-- RESUMEN DE CAMBIOS:
|
||||
-- A) Tablas solo en LOCAL → agregar a producción y futuras instalaciones:
|
||||
-- - scheduled_messages
|
||||
-- - scheduled_messages_view (VIEW)
|
||||
-- B) Tablas solo en PROD → agregar a local y futuras instalaciones:
|
||||
-- - roles
|
||||
-- - role_modules
|
||||
-- - lab_config
|
||||
-- C) Columnas en PROD que faltan en LOCAL y futuras instalaciones:
|
||||
-- - admin_users.role_id
|
||||
-- - lab_form_envios.hash_verificacion
|
||||
-- - lab_formularios.doc_encabezado, doc_subtitulo, doc_logo_base64,
|
||||
-- doc_color, doc_pie_pagina
|
||||
-- D) Ya cubierto en 05_terms_acceptance.sql (no se repite):
|
||||
-- - terms_versions, terms_acceptance
|
||||
-- - users.terms_pending, terms_accepted_at, terms_version_id
|
||||
-- =============================================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET foreign_key_checks = 0;
|
||||
|
||||
-- =============================================================================
|
||||
-- C0) COLUMNAS: message_templates (guard por si 02 no aplicó completo)
|
||||
-- =============================================================================
|
||||
ALTER TABLE `message_templates`
|
||||
ADD COLUMN IF NOT EXISTS `body_text` TEXT DEFAULT NULL COMMENT 'Texto del cuerpo con variables {{1}},{{2}}…',
|
||||
ADD COLUMN IF NOT EXISTS `components` LONGTEXT DEFAULT NULL COMMENT 'Componentes JSON completos de la plantilla',
|
||||
ADD COLUMN IF NOT EXISTS `example_parameters` LONGTEXT DEFAULT NULL COMMENT 'Ejemplos de valores para los parámetros',
|
||||
ADD COLUMN IF NOT EXISTS `header_text` VARCHAR(255) DEFAULT NULL COMMENT 'Texto del header',
|
||||
ADD COLUMN IF NOT EXISTS `header_type` ENUM('text','image','video','document') DEFAULT NULL COMMENT 'Tipo de header',
|
||||
ADD COLUMN IF NOT EXISTS `footer_text` VARCHAR(255) DEFAULT NULL COMMENT 'Texto del footer',
|
||||
ADD COLUMN IF NOT EXISTS `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;
|
||||
|
||||
ALTER TABLE `message_templates`
|
||||
ADD INDEX IF NOT EXISTS `idx_template_language` (`template_name`, `language_code`);
|
||||
|
||||
-- =============================================================================
|
||||
-- A1) TABLA: scheduled_messages (local → prod / instalaciones nuevas)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS `scheduled_messages` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`template_id` int(11) DEFAULT NULL,
|
||||
`template_name` varchar(100) DEFAULT NULL,
|
||||
`template_language` varchar(10) DEFAULT 'es',
|
||||
`template_parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL
|
||||
COMMENT 'Parámetros para las variables de la plantilla'
|
||||
CHECK (json_valid(`template_parameters`)),
|
||||
`message_type` enum('text','template') DEFAULT 'template',
|
||||
`message_content` text DEFAULT NULL COMMENT 'Contenido del mensaje si es tipo text',
|
||||
`scheduled_date` date NOT NULL,
|
||||
`scheduled_time` time NOT NULL,
|
||||
`status` enum('pending','sent','failed','cancelled') DEFAULT 'pending',
|
||||
`sent_at` datetime DEFAULT NULL,
|
||||
`error_message` text DEFAULT NULL,
|
||||
`created_by` int(11) DEFAULT NULL COMMENT 'ID del admin que creó el recordatorio',
|
||||
`created_at` datetime DEFAULT current_timestamp(),
|
||||
`updated_at` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_scheduled_date` (`scheduled_date`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_scheduled_datetime` (`scheduled_date`,`scheduled_time`,`status`),
|
||||
CONSTRAINT `scheduled_messages_ibfk_1`
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- =============================================================================
|
||||
-- A2) VISTA: scheduled_messages_view (local → prod / instalaciones nuevas)
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE VIEW `scheduled_messages_view` AS
|
||||
SELECT
|
||||
`sm`.`id`,
|
||||
`sm`.`user_id`,
|
||||
`sm`.`template_id`,
|
||||
`sm`.`template_name`,
|
||||
`sm`.`template_language`,
|
||||
`sm`.`template_parameters`,
|
||||
`sm`.`message_type`,
|
||||
`sm`.`message_content`,
|
||||
`sm`.`scheduled_date`,
|
||||
`sm`.`scheduled_time`,
|
||||
`sm`.`status`,
|
||||
`sm`.`sent_at`,
|
||||
`sm`.`error_message`,
|
||||
`sm`.`created_by`,
|
||||
`sm`.`created_at`,
|
||||
`sm`.`updated_at`,
|
||||
`u`.`name` AS `user_name`,
|
||||
`u`.`phone_number` AS `phone_number`,
|
||||
`mt`.`name` AS `template_display_name`,
|
||||
`mt`.`body_text` AS `template_body`,
|
||||
`mt`.`status` AS `template_status`
|
||||
FROM `scheduled_messages` `sm`
|
||||
LEFT JOIN `users` `u` ON `sm`.`user_id` = `u`.`id`
|
||||
LEFT JOIN `message_templates` `mt` ON `sm`.`template_id` = `mt`.`id`
|
||||
ORDER BY `sm`.`scheduled_date` DESC, `sm`.`scheduled_time` DESC;
|
||||
|
||||
-- =============================================================================
|
||||
-- B1) TABLA: roles (prod → local / instalaciones nuevas)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS `roles` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) NOT NULL COMMENT 'Nombre legible del rol',
|
||||
`slug` varchar(50) NOT NULL COMMENT 'Clave interna (enfermero, admin, …)',
|
||||
`description` text DEFAULT NULL,
|
||||
`color` varchar(20) DEFAULT '#6c757d' COMMENT 'Color HEX para la UI',
|
||||
`is_system` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1 = no se puede eliminar',
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_slug` (`slug`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Datos semilla mínimos de roles del sistema
|
||||
INSERT IGNORE INTO `roles` (`id`, `name`, `slug`, `description`, `is_system`) VALUES
|
||||
(1, 'Administrador', 'admin', 'Acceso completo al sistema', 1),
|
||||
(2, 'Enfermero', 'enfermero', 'Acceso a módulos de laboratorio y atención', 1);
|
||||
|
||||
-- =============================================================================
|
||||
-- B2) TABLA: role_modules (prod → local / instalaciones nuevas)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS `role_modules` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`role_id` int(11) NOT NULL,
|
||||
`module_slug` varchar(100) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_role_module` (`role_id`, `module_slug`),
|
||||
CONSTRAINT `fk_rm_role`
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- =============================================================================
|
||||
-- B3) TABLA: lab_config (prod → local / instalaciones nuevas)
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS `lab_config` (
|
||||
`clave` varchar(80) NOT NULL,
|
||||
`valor` longtext NOT NULL DEFAULT '',
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`clave`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- =============================================================================
|
||||
-- C1) COLUMNA: admin_users.role_id (prod → local / instalaciones nuevas)
|
||||
-- =============================================================================
|
||||
ALTER TABLE `admin_users`
|
||||
ADD COLUMN IF NOT EXISTS `role_id` int(11) DEFAULT NULL AFTER `password`;
|
||||
|
||||
-- C1a) FK opcional: se añade solo si no existe ya
|
||||
-- (MariaDB/MySQL 10.x: ADD CONSTRAINT IF NOT EXISTS no es estándar,
|
||||
-- se ignora el error si ya existe usando INSERT IGNORE pattern)
|
||||
ALTER TABLE `admin_users`
|
||||
ADD CONSTRAINT IF NOT EXISTS `fk_admin_role`
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- =============================================================================
|
||||
-- C2) COLUMNA: lab_form_envios.hash_verificacion (prod → local)
|
||||
-- =============================================================================
|
||||
ALTER TABLE `lab_form_envios`
|
||||
ADD COLUMN IF NOT EXISTS `hash_verificacion` char(64) DEFAULT NULL
|
||||
COMMENT 'SHA-256 del PDF generado, para verificación de integridad'
|
||||
AFTER `pdf_path`;
|
||||
|
||||
-- =============================================================================
|
||||
-- C3) COLUMNAS: lab_formularios doc_* (prod → local / instalaciones nuevas)
|
||||
-- =============================================================================
|
||||
ALTER TABLE `lab_formularios`
|
||||
ADD COLUMN IF NOT EXISTS `doc_encabezado` varchar(200) DEFAULT NULL COMMENT 'Título principal del documento PDF',
|
||||
ADD COLUMN IF NOT EXISTS `doc_subtitulo` varchar(200) DEFAULT NULL COMMENT 'Subtítulo / razón social',
|
||||
ADD COLUMN IF NOT EXISTS `doc_logo_base64` longtext DEFAULT NULL COMMENT 'Logo en Base64 para el PDF',
|
||||
ADD COLUMN IF NOT EXISTS `doc_color` varchar(20) DEFAULT NULL COMMENT 'Color principal HEX (#RRGGBB)',
|
||||
ADD COLUMN IF NOT EXISTS `doc_pie_pagina` varchar(500) DEFAULT NULL COMMENT 'Texto del pie de página';
|
||||
|
||||
-- =============================================================================
|
||||
-- FIN
|
||||
-- =============================================================================
|
||||
SET foreign_key_checks = 1;
|
||||
|
||||
-- Verificación rápida (opcional, descomentar para depuración)
|
||||
-- SELECT 'scheduled_messages' AS tabla, COUNT(*) AS filas FROM scheduled_messages
|
||||
-- UNION ALL SELECT 'roles', COUNT(*) FROM roles
|
||||
-- UNION ALL SELECT 'role_modules', COUNT(*) FROM role_modules
|
||||
-- UNION ALL SELECT 'lab_config', COUNT(*) FROM lab_config;
|
||||
@@ -0,0 +1,658 @@
|
||||
# Base de datos — `usite_whatsapp_bot`
|
||||
|
||||
Documentación de las 34 tablas del sistema WhatsApp Bot + Panel Laboratorio Ximena.
|
||||
Motor: **MariaDB / MySQL 8.x** — Charset: `utf8mb4` — Collation: `utf8mb4_unicode_ci`
|
||||
|
||||
---
|
||||
|
||||
## Índice rápido
|
||||
|
||||
| # | Tabla | Filas (prod) | Dominio |
|
||||
|---|-------|-------------|---------|
|
||||
| 1 | [admin\_users](#1-admin_users) | 4 | Autenticación |
|
||||
| 2 | [autoresponses](#2-autoresponses) | 6 | Bot WhatsApp |
|
||||
| 3 | [conversations](#3-conversations) | 3 490 | Bot WhatsApp |
|
||||
| 4 | [file\_request\_uploads](#4-file_request_uploads) | 0 | Solicitud archivos |
|
||||
| 5 | [file\_requests](#5-file_requests) | 0 | Solicitud archivos |
|
||||
| 6 | [lab\_actividad\_admin](#6-lab_actividad_admin) | 43 | Auditoría |
|
||||
| 7 | [lab\_asignaciones](#7-lab_asignaciones) | 4 | Laboratorio |
|
||||
| 8 | [lab\_autorizaciones](#8-lab_autorizaciones) | 0 | Laboratorio |
|
||||
| 9 | [lab\_config](#9-lab_config) | 9 | Laboratorio |
|
||||
| 10 | [lab\_domicilios](#10-lab_domicilios) | 9 | Laboratorio |
|
||||
| 11 | [lab\_enfermeras](#11-lab_enfermeras) | 1 | Laboratorio |
|
||||
| 12 | [lab\_form\_envios](#12-lab_form_envios) | 15 | Formularios |
|
||||
| 13 | [lab\_formularios](#13-lab_formularios) | 3 | Formularios |
|
||||
| 14 | [lab\_ordenes\_medicas](#14-lab_ordenes_medicas) | 0 | Laboratorio |
|
||||
| 15 | [lab\_pacientes](#15-lab_pacientes) | 3 | Laboratorio |
|
||||
| 16 | [lab\_servicios\_extra](#16-lab_servicios_extra) | 0 | Laboratorio |
|
||||
| 17 | [media\_files](#17-media_files) | 231 | Multimedia |
|
||||
| 18 | [media\_queue](#18-media_queue) | 7 | Multimedia |
|
||||
| 19 | [menu\_options](#19-menu_options) | 13 | Bot WhatsApp |
|
||||
| 20 | [menus](#20-menus) | 2 | Bot WhatsApp |
|
||||
| 21 | [message\_templates](#21-message_templates) | 2 | Bot WhatsApp |
|
||||
| 22 | [migrations](#22-migrations) | 4 | Sistema |
|
||||
| 23 | [notifications](#23-notifications) | 2 073 | Sistema |
|
||||
| 24 | [operator\_activity](#24-operator_activity) | 227 | Operadores |
|
||||
| 25 | [role\_modules](#25-role_modules) | 12 | Control de acceso |
|
||||
| 26 | [roles](#26-roles) | 2 | Control de acceso |
|
||||
| 27 | [scheduled\_messages](#27-scheduled_messages) | — | Mensajes prog. |
|
||||
| 28 | [scheduled\_messages\_view](#28-scheduled_messages_view-vista) | — | Vista |
|
||||
| 29 | [survey\_responses](#29-survey_responses) | 10 | Bot WhatsApp |
|
||||
| 30 | [system\_config](#30-system_config) | 16 | Configuración |
|
||||
| 31 | [system\_logs](#31-system_logs) | 0 | Sistema |
|
||||
| 32 | [terms\_acceptance](#32-terms_acceptance) | 0 | T&C |
|
||||
| 33 | [terms\_versions](#33-terms_versions) | 2 | T&C |
|
||||
| 34 | [user\_states](#34-user_states) | 108 | Bot WhatsApp |
|
||||
| 35 | [users](#35-users) | 3 090 | Usuarios |
|
||||
| 36 | [webhook\_logs](#36-webhook_logs) | 9 850 | Sistema |
|
||||
|
||||
---
|
||||
|
||||
## Autenticación
|
||||
|
||||
### 1. `admin_users`
|
||||
Administradores del panel web.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `username` | VARCHAR(50) UNIQUE | Login |
|
||||
| `password` | VARCHAR(255) | bcrypt |
|
||||
| `role_id` | INT FK→roles | Rol asignado (NULL = admin clásico) |
|
||||
| `email` | VARCHAR(100) | |
|
||||
| `nombre` | VARCHAR(100) | Nombre completo |
|
||||
| `is_active` | TINYINT(1) | 1 = activo |
|
||||
| `last_login` | DATETIME | |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
**Relaciones:** `role_id` → `roles.id`
|
||||
|
||||
---
|
||||
|
||||
## Bot WhatsApp
|
||||
|
||||
### 2. `autoresponses`
|
||||
Respuestas automáticas del bot ante palabras clave.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `keyword` | VARCHAR(100) | Palabra/frase que activa la respuesta |
|
||||
| `response` | TEXT | Texto de respuesta |
|
||||
| `is_active` | TINYINT(1) | |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 3. `conversations`
|
||||
Hilo de mensajes WhatsApp por número de teléfono.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `phone_number` | VARCHAR(20) | Número del usuario |
|
||||
| `contact_name` | VARCHAR(100) | Nombre de WhatsApp |
|
||||
| `messages` | LONGTEXT (JSON) | Array de mensajes |
|
||||
| `last_message` | TEXT | Extracto del último mensaje |
|
||||
| `last_message_at` | DATETIME | |
|
||||
| `unread_count` | INT | Mensajes no leídos por operador |
|
||||
| `status` | ENUM | `active`, `archived`, `blocked` |
|
||||
| `assigned_to` | INT FK→admin_users | Operador asignado |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 4. `file_request_uploads`
|
||||
Archivos subidos en respuesta a solicitudes.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `request_id` | INT FK→file_requests | |
|
||||
| `file_path` | VARCHAR(500) | Ruta en servidor |
|
||||
| `file_type` | VARCHAR(50) | MIME |
|
||||
| `uploaded_at` | DATETIME | |
|
||||
|
||||
---
|
||||
|
||||
### 5. `file_requests`
|
||||
Solicitudes de documentos enviadas por WhatsApp.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `phone_number` | VARCHAR(20) | |
|
||||
| `request_type` | VARCHAR(50) | Tipo de documento solicitado |
|
||||
| `status` | ENUM | `pending`, `fulfilled`, `expired` |
|
||||
| `expires_at` | DATETIME | |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 19. `menu_options`
|
||||
Opciones de submenú del bot (hijos de `menus`).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `menu_id` | INT FK→menus | |
|
||||
| `option_number` | INT | Tecla que selecciona el usuario |
|
||||
| `option_text` | VARCHAR(200) | Texto visible |
|
||||
| `action_type` | VARCHAR(50) | `submenu`, `message`, `url`, `form_link` |
|
||||
| `action_value` | TEXT | Valor de la acción |
|
||||
| `is_active` | TINYINT(1) | |
|
||||
| `sort_order` | INT | |
|
||||
|
||||
---
|
||||
|
||||
### 20. `menus`
|
||||
Menús raíz del bot por número de teléfono de negocio.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `phone_number_id` | VARCHAR(50) | ID de WhatsApp Business |
|
||||
| `menu_text` | TEXT | Texto del menú principal |
|
||||
| `is_active` | TINYINT(1) | |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 21. `message_templates`
|
||||
Plantillas de mensajes aprobadas por Meta.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `name` | VARCHAR(100) | Nombre interno |
|
||||
| `display_name` | VARCHAR(150) | Nombre legible |
|
||||
| `category` | VARCHAR(50) | `MARKETING`, `UTILITY`, `AUTHENTICATION` |
|
||||
| `language` | VARCHAR(10) | `es`, `en`, etc. |
|
||||
| `status` | VARCHAR(30) | `APPROVED`, `PENDING`, `REJECTED` |
|
||||
| `header_type` | VARCHAR(20) | `TEXT`, `IMAGE`, `DOCUMENT`, `NONE` |
|
||||
| `header_text` | VARCHAR(500) | |
|
||||
| `body_text` | TEXT | Cuerpo de la plantilla |
|
||||
| `footer_text` | VARCHAR(300) | |
|
||||
| `buttons` | LONGTEXT (JSON) | Botones quick-reply / CTA |
|
||||
| `variables_count` | INT | Número de `{{n}}` en body |
|
||||
| `meta_template_id` | VARCHAR(50) | ID en Meta Graph API |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 29. `survey_responses`
|
||||
Respuestas a encuestas del bot.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `phone_number` | VARCHAR(20) | |
|
||||
| `survey_id` | INT | Identificador de encuesta |
|
||||
| `question_key` | VARCHAR(100) | |
|
||||
| `answer` | TEXT | |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 34. `user_states`
|
||||
Estado de conversación del bot por usuario (máquina de estados).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `phone_number` | VARCHAR(20) UNIQUE | |
|
||||
| `state` | VARCHAR(100) | Estado actual del flujo del bot |
|
||||
| `context` | LONGTEXT (JSON) | Datos contextuales del estado |
|
||||
| `updated_at` | DATETIME | |
|
||||
|
||||
---
|
||||
|
||||
## Laboratorio
|
||||
|
||||
### 6. `lab_actividad_admin`
|
||||
Log de acciones de administradores en el laboratorio.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `admin_id` | INT FK→admin_users | |
|
||||
| `accion` | VARCHAR(100) | Descripción de la acción |
|
||||
| `entidad` | VARCHAR(50) | Tabla/módulo afectado |
|
||||
| `entidad_id` | INT | ID del registro afectado |
|
||||
| `datos_extra` | TEXT (JSON) | Detalles adicionales |
|
||||
| `ip` | VARCHAR(45) | |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 7. `lab_asignaciones`
|
||||
Asignaciones de servicios domiciliarios a enfermeras.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `domicilio_id` | INT FK→lab_domicilios | |
|
||||
| `enfermera_id` | INT FK→lab_enfermeras | |
|
||||
| `estado` | ENUM | `pendiente`, `aceptado`, `rechazado`, `completado` |
|
||||
| `fecha_asignacion` | DATETIME | |
|
||||
| `fecha_respuesta` | DATETIME | |
|
||||
| `notas` | TEXT | |
|
||||
|
||||
---
|
||||
|
||||
### 8. `lab_autorizaciones`
|
||||
Autorizaciones médicas/seguros para servicios del laboratorio.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `paciente_id` | INT FK→lab_pacientes | |
|
||||
| `tipo` | VARCHAR(50) | Tipo de autorización |
|
||||
| `numero_autorizacion` | VARCHAR(100) | |
|
||||
| `aseguradora` | VARCHAR(100) | |
|
||||
| `fecha_vencimiento` | DATE | |
|
||||
| `estado` | ENUM | `vigente`, `vencida` |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 9. `lab_config`
|
||||
Configuración específica del módulo de laboratorio (clave–valor).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `clave` | VARCHAR(80) PK | Nombre de la configuración |
|
||||
| `valor` | LONGTEXT | Valor |
|
||||
| `updated_at` | TIMESTAMP | |
|
||||
|
||||
Ejemplos de claves: `empresa_nombre`, `empresa_nit`, `empresa_direccion`, `empresa_telefono`, `empresa_email`, `empresa_ciudad`, etc.
|
||||
|
||||
---
|
||||
|
||||
### 10. `lab_domicilios`
|
||||
Solicitudes de servicio domiciliario de laboratorio.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `paciente_id` | INT FK→lab_pacientes | |
|
||||
| `fecha_solicitud` | DATE | |
|
||||
| `hora_solicitud` | TIME | |
|
||||
| `direccion` | VARCHAR(300) | |
|
||||
| `ciudad` | VARCHAR(100) | |
|
||||
| `estado` | ENUM | `pendiente`, `asignado`, `en_camino`, `completado`, `cancelado` |
|
||||
| `observaciones` | TEXT | |
|
||||
| `examen_solicitado` | TEXT | |
|
||||
| `creado_por` | INT FK→admin_users | |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 11. `lab_enfermeras`
|
||||
Enfermeras/profesionales de campo del laboratorio.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `nombre` | VARCHAR(150) | |
|
||||
| `telefono` | VARCHAR(20) | |
|
||||
| `email` | VARCHAR(100) | |
|
||||
| `phone_number_wa` | VARCHAR(20) | Número WhatsApp para notificaciones |
|
||||
| `is_active` | TINYINT(1) | |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 12. `lab_form_envios`
|
||||
Instancias de formularios enviados/completados por pacientes.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `formulario_id` | INT FK→lab_formularios | |
|
||||
| `paciente_id` | INT FK→lab_pacientes | |
|
||||
| `phone_number` | VARCHAR(20) | |
|
||||
| `datos_json` | LONGTEXT (JSON) | Respuestas del formulario |
|
||||
| `firma_base64` | LONGTEXT | Firma digital en Base64 |
|
||||
| `pdf_path` | VARCHAR(500) | Ruta del PDF generado |
|
||||
| `hash_verificacion` | CHAR(64) | SHA-256 del PDF para integridad |
|
||||
| `estado` | ENUM | `borrador`, `enviado`, `procesado` |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 13. `lab_formularios`
|
||||
Definición de formularios médicos configurables.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `nombre` | VARCHAR(150) | |
|
||||
| `descripcion` | TEXT | |
|
||||
| `categoria` | ENUM | `consentimiento`, `historia_clinica`, `autorizacion`, `encuesta`, `otro` |
|
||||
| `esquema` | LONGTEXT (JSON) | Definición de campos del formulario |
|
||||
| `permite_firma` | TINYINT(1) | |
|
||||
| `requiere_firma` | TINYINT(1) | |
|
||||
| `version` | SMALLINT | |
|
||||
| `is_active` | TINYINT(1) | |
|
||||
| `doc_encabezado` | VARCHAR(200) | Título en PDF |
|
||||
| `doc_subtitulo` | VARCHAR(200) | Subtítulo en PDF |
|
||||
| `doc_logo_base64` | LONGTEXT | Logo en Base64 para PDF |
|
||||
| `doc_color` | VARCHAR(20) | Color principal HEX para PDF |
|
||||
| `doc_pie_pagina` | VARCHAR(500) | Pie de página del PDF |
|
||||
| `creado_por` | INT FK→admin_users | |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 14. `lab_ordenes_medicas`
|
||||
Órdenes médicas digitalizadas vinculadas a pacientes.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `paciente_id` | INT FK→lab_pacientes | |
|
||||
| `medico` | VARCHAR(150) | |
|
||||
| `especialidad` | VARCHAR(100) | |
|
||||
| `fecha_orden` | DATE | |
|
||||
| `examenes` | TEXT | Exámenes ordenados |
|
||||
| `archivo_path` | VARCHAR(500) | Imagen/PDF de la orden |
|
||||
| `estado` | ENUM | `pendiente`, `procesada`, `vigente`, `vencida` |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 15. `lab_pacientes`
|
||||
Pacientes registrados en el laboratorio.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `nombre` | VARCHAR(150) | |
|
||||
| `documento` | VARCHAR(30) | Cédula / NIT |
|
||||
| `tipo_documento` | VARCHAR(20) | CC, TI, CE, PA, NIT |
|
||||
| `fecha_nacimiento` | DATE | |
|
||||
| `telefono` | VARCHAR(20) | |
|
||||
| `email` | VARCHAR(100) | |
|
||||
| `direccion` | VARCHAR(300) | |
|
||||
| `eps` | VARCHAR(100) | Aseguradora de salud |
|
||||
| `phone_number_wa` | VARCHAR(20) | Número WhatsApp → `users.phone_number` |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 16. `lab_servicios_extra`
|
||||
Servicios adicionales configurables del laboratorio (transporte, procesamiento especial, etc.).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `nombre` | VARCHAR(100) | |
|
||||
| `descripcion` | TEXT | |
|
||||
| `precio` | DECIMAL(10,2) | |
|
||||
| `is_active` | TINYINT(1) | |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
## Multimedia
|
||||
|
||||
### 17. `media_files`
|
||||
Archivos multimedia subidos o recibidos por WhatsApp.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `media_id` | VARCHAR(100) | ID en la API de WhatsApp |
|
||||
| `file_name` | VARCHAR(255) | |
|
||||
| `file_path` | VARCHAR(500) | Ruta local |
|
||||
| `file_size` | BIGINT | Bytes |
|
||||
| `mime_type` | VARCHAR(100) | |
|
||||
| `phone_number` | VARCHAR(20) | Remitente o destinatario |
|
||||
| `direction` | ENUM | `incoming`, `outgoing` |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 18. `media_queue`
|
||||
Cola de transmisión de archivos multimedia grandes.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `media_file_id` | INT FK→media_files | |
|
||||
| `status` | ENUM | `pending`, `processing`, `done`, `failed` |
|
||||
| `attempts` | INT | Reintentos |
|
||||
| `error_message` | TEXT | |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
## Mensajes Programados
|
||||
|
||||
### 27. `scheduled_messages`
|
||||
Mensajes de WhatsApp programados para envío futuro.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `user_id` | INT FK→users | Destinatario |
|
||||
| `template_id` | INT FK→message_templates | Plantilla a usar |
|
||||
| `template_name` | VARCHAR(100) | Nombre snapshot de la plantilla |
|
||||
| `template_language` | VARCHAR(10) | `es`, `en` |
|
||||
| `template_parameters` | LONGTEXT (JSON) | Variables `{{n}}` |
|
||||
| `message_type` | ENUM | `template`, `text` |
|
||||
| `message_content` | TEXT | Mensaje libre (si type=text) |
|
||||
| `scheduled_date` | DATE | Fecha de envío |
|
||||
| `scheduled_time` | TIME | Hora de envío |
|
||||
| `status` | ENUM | `pending`, `sent`, `failed`, `cancelled` |
|
||||
| `sent_at` | DATETIME | |
|
||||
| `error_message` | TEXT | |
|
||||
| `created_by` | INT FK→admin_users | Admin que creó el recordatorio |
|
||||
| `created_at` / `updated_at` | DATETIME | |
|
||||
|
||||
---
|
||||
|
||||
### 28. `scheduled_messages_view` (VISTA)
|
||||
Enriquece `scheduled_messages` con datos del usuario, plantilla y nombre del admin.
|
||||
|
||||
Columnas adicionales: `user_name`, `phone_number`, `template_display_name`, `template_body`, `template_status`.
|
||||
|
||||
---
|
||||
|
||||
## Control de Acceso
|
||||
|
||||
### 25. `role_modules`
|
||||
Asociación entre roles y módulos del sistema (permisos).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `role_id` | INT FK→roles | |
|
||||
| `module_slug` | VARCHAR(100) | Clave del módulo (ej. `lab`, `bot`, `config`) |
|
||||
|
||||
---
|
||||
|
||||
### 26. `roles`
|
||||
Roles del sistema para control de acceso basado en roles (RBAC).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `name` | VARCHAR(100) | Nombre legible |
|
||||
| `slug` | VARCHAR(50) UNIQUE | Clave interna (`admin`, `enfermero`) |
|
||||
| `description` | TEXT | |
|
||||
| `color` | VARCHAR(20) | Color HEX para UI |
|
||||
| `is_system` | TINYINT(1) | 1 = no eliminable |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
Roles de sistema predefinidos: `admin` (id=1), `enfermero` (id=2).
|
||||
|
||||
---
|
||||
|
||||
## Términos y Condiciones (T&C)
|
||||
|
||||
### 32. `terms_acceptance`
|
||||
Registro de aceptaciones/rechazos de T&C por usuario.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `phone_number` | VARCHAR(20) | |
|
||||
| `version_id` | INT FK→terms_versions | Versión enviada |
|
||||
| `estado` | ENUM | `pendiente`, `aceptado`, `rechazado` |
|
||||
| `fecha_envio` | DATETIME | Cuándo se enviaron los T&C |
|
||||
| `fecha_respuesta` | DATETIME | Cuándo respondió el usuario |
|
||||
| `ip_origem` | VARCHAR(45) | IP de origen (si aplica) |
|
||||
|
||||
---
|
||||
|
||||
### 33. `terms_versions`
|
||||
Versiones publicadas de los Términos y Condiciones.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `version` | VARCHAR(20) | Ej. `v1.0` |
|
||||
| `titulo` | VARCHAR(200) | |
|
||||
| `mensaje_bot` | TEXT | Texto enviado por WhatsApp |
|
||||
| `mensaje_rechazo` | TEXT | Texto si el usuario rechaza |
|
||||
| `pdf_url` | VARCHAR(500) | URL pública del PDF |
|
||||
| `pdf_path` | VARCHAR(500) | Ruta local del archivo |
|
||||
| `es_activa` | TINYINT(1) | Solo una activa a la vez |
|
||||
| `forzar_reenvio` | TINYINT(1) | 1 = todos deben re-aceptar |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
| `created_by` | INT FK→admin_users | |
|
||||
|
||||
---
|
||||
|
||||
## Configuración y Sistema
|
||||
|
||||
### 22. `migrations`
|
||||
Registro de migraciones de BD aplicadas.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `name` | VARCHAR(255) | Nombre del archivo de migración |
|
||||
| `applied_at` | TIMESTAMP | |
|
||||
|
||||
Migraciones aplicadas: `01_initial.sql`, `02_…`, `03_…`, `05_terms_acceptance.sql`, `06_master_sync.sql`.
|
||||
|
||||
---
|
||||
|
||||
### 23. `notifications`
|
||||
Notificaciones del sistema enviadas a admins.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `admin_id` | INT FK→admin_users | NULL = broadcast |
|
||||
| `type` | VARCHAR(50) | `new_message`, `assignment`, `system` |
|
||||
| `title` | VARCHAR(200) | |
|
||||
| `body` | TEXT | |
|
||||
| `is_read` | TINYINT(1) | |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 24. `operator_activity`
|
||||
Actividad reciente de operadores (mensajes enviados, conversaciones gestionadas).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `admin_id` | INT FK→admin_users | |
|
||||
| `action` | VARCHAR(100) | `send_message`, `view_conversation`, etc. |
|
||||
| `phone_number` | VARCHAR(20) | Número implicado |
|
||||
| `details` | TEXT (JSON) | |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 30. `system_config`
|
||||
Configuración global del sistema (clave–valor).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `config_key` | VARCHAR(100) UNIQUE | |
|
||||
| `config_value` | TEXT | |
|
||||
| `description` | VARCHAR(255) | |
|
||||
| `updated_at` | TIMESTAMP | |
|
||||
|
||||
Claves principales: `whatsapp_token`, `whatsapp_phone_number_id`, `whatsapp_verify_token`, `bot_enabled`, `welcome_message`, `terms_message`, `terms_rejection_message`, `terms_pdf_url`, `terms_version`, `terms_force_resend`.
|
||||
|
||||
---
|
||||
|
||||
### 31. `system_logs`
|
||||
Logs de errores y eventos del sistema.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `level` | ENUM | `info`, `warning`, `error`, `critical` |
|
||||
| `message` | TEXT | |
|
||||
| `context` | LONGTEXT (JSON) | Stack trace, datos extra |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
### 36. `webhook_logs`
|
||||
Log de todos los webhooks entrantes de WhatsApp.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `payload` | LONGTEXT (JSON) | Payload completo recibido |
|
||||
| `event_type` | VARCHAR(50) | `message`, `status`, `read` |
|
||||
| `phone_number` | VARCHAR(20) | |
|
||||
| `processed` | TINYINT(1) | |
|
||||
| `error` | TEXT | Error si falló el procesamiento |
|
||||
| `created_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
## Usuarios
|
||||
|
||||
### 35. `users`
|
||||
Usuarios de WhatsApp que han interactuado con el bot.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---------|------|-------|
|
||||
| `id` | INT PK | |
|
||||
| `phone_number` | VARCHAR(20) UNIQUE | Número de WhatsApp |
|
||||
| `name` | VARCHAR(100) | Nombre de contacto en WhatsApp |
|
||||
| `is_blocked` | TINYINT(1) | 1 = bloqueado |
|
||||
| `bot_enabled` | TINYINT(1) | 1 = bot activo para este usuario |
|
||||
| `last_interaction` | DATETIME | |
|
||||
| `terms_pending` | TINYINT(1) | 1 = aún no aceptó T&C |
|
||||
| `terms_accepted_at` | DATETIME | Fecha de aceptación |
|
||||
| `terms_version_id` | INT FK→terms_versions | Versión aceptada |
|
||||
| `created_at` / `updated_at` | TIMESTAMP | |
|
||||
|
||||
---
|
||||
|
||||
## Diagrama ER simplificado
|
||||
|
||||
```
|
||||
users ──────────────────────────────────┐
|
||||
│ 1:N terms_acceptance │
|
||||
│ 1:N scheduled_messages │
|
||||
│ 1:N conversations │ user_states (1:1 phone_number)
|
||||
│ │
|
||||
admin_users ─────── roles ──── role_modules
|
||||
│ 1:N lab_actividad_admin
|
||||
│ 1:N lab_domicilios ──── lab_asignaciones ── lab_enfermeras
|
||||
│ 1:N lab_form_envios ── lab_formularios
|
||||
│ 1:N lab_ordenes_medicas ── lab_pacientes ── lab_autorizaciones
|
||||
│ 1:N terms_versions (created_by)
|
||||
│
|
||||
menus ─── menu_options
|
||||
message_templates ─── scheduled_messages
|
||||
media_files ─── media_queue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Última actualización: generado automáticamente desde comparación local/prod — ver `database/06_master_sync.sql`*
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
/**
|
||||
* Script de comparación de bases de datos: local vs producción
|
||||
* Genera la estructura completa de tablas, columnas, índices y constraints
|
||||
*/
|
||||
|
||||
require '/var/www/html/config/config.php';
|
||||
|
||||
// Conexión producción (usa la config normal del sistema)
|
||||
$pdoProd = new PDO(
|
||||
'mysql:host='.DB_HOST.';port='.DB_PORT.';dbname='.DB_NAME.';charset=utf8mb4',
|
||||
DB_USER, DB_PASS,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
|
||||
// Conexión local (MariaDB del docker-compose dev)
|
||||
$localHost = 'whatsapp-dev-mysql';
|
||||
$localUser = 'root';
|
||||
$localPass = 'root_password_2026';
|
||||
$localDb = DB_NAME;
|
||||
|
||||
$pdoLocal = null;
|
||||
try {
|
||||
$pdoLocal = new PDO(
|
||||
"mysql:host={$localHost};port=3306;dbname={$localDb};charset=utf8mb4",
|
||||
$localUser, $localPass,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
// Si el host no es accesible desde el contenedor app, intentar con la dirección de red interna
|
||||
$localHost = '172.17.0.1'; // docker bridge gateway
|
||||
try {
|
||||
$pdoLocal = new PDO(
|
||||
"mysql:host={$localHost};port=3306;dbname={$localDb};charset=utf8mb4",
|
||||
$localUser, $localPass,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
} catch (Exception $e2) {
|
||||
echo json_encode(['local_error' => $e2->getMessage()]);
|
||||
// Continuar solo con producción
|
||||
}
|
||||
}
|
||||
|
||||
function getSchema(PDO $pdo, string $dbName): array {
|
||||
// Tablas
|
||||
$tables = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$schema = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
// Columnas
|
||||
$cols = $pdo->query("SHOW FULL COLUMNS FROM `$table`")->fetchAll();
|
||||
// Índices
|
||||
$indexes = $pdo->query("SHOW INDEX FROM `$table`")->fetchAll();
|
||||
// CREATE TABLE para referencia exacta
|
||||
$create = $pdo->query("SHOW CREATE TABLE `$table`")->fetch();
|
||||
|
||||
$schema[$table] = [
|
||||
'columns' => $cols,
|
||||
'indexes' => $indexes,
|
||||
'create_sql' => $create['Create Table'] ?? '',
|
||||
];
|
||||
}
|
||||
return $schema;
|
||||
}
|
||||
|
||||
function getRowCounts(PDO $pdo): array {
|
||||
$tables = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$counts = [];
|
||||
foreach ($tables as $t) {
|
||||
$r = $pdo->query("SELECT COUNT(*) as n FROM `$t`")->fetch();
|
||||
$counts[$t] = (int)($r['n'] ?? 0);
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
$prodSchema = getSchema($pdoProd, DB_NAME);
|
||||
$prodCounts = getRowCounts($pdoProd);
|
||||
$localSchema = $pdoLocal ? getSchema($pdoLocal, $localDb) : null;
|
||||
$localCounts = $pdoLocal ? getRowCounts($pdoLocal) : null;
|
||||
|
||||
$output = [
|
||||
'prod_tables' => array_keys($prodSchema),
|
||||
'local_tables' => $localSchema ? array_keys($localSchema) : null,
|
||||
'prod_counts' => $prodCounts,
|
||||
'local_counts' => $localCounts,
|
||||
'prod_schema' => $prodSchema,
|
||||
'local_schema' => $localSchema,
|
||||
];
|
||||
|
||||
file_put_contents('/tmp/db_scan_result.json', json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
echo "DONE: " . count($prodSchema) . " prod tables" . ($localSchema ? ", " . count($localSchema) . " local tables" : ", local not available") . PHP_EOL;
|
||||
echo "Output saved to /tmp/db_scan_result.json" . PHP_EOL;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
$data = json_decode(file_get_contents('/tmp/db_scan_result.json'), true);
|
||||
$prod = $data['prod_tables'];
|
||||
$local = $data['local_tables'];
|
||||
sort($prod); sort($local);
|
||||
|
||||
echo "=== SOLO EN PRODUCCIÓN (faltan en local) ===" . PHP_EOL;
|
||||
foreach (array_diff($prod, $local) as $t) {
|
||||
echo " + $t (" . $data['prod_counts'][$t] . " rows)" . PHP_EOL;
|
||||
}
|
||||
|
||||
echo PHP_EOL . "=== SOLO EN LOCAL (faltan en producción) ===" . PHP_EOL;
|
||||
foreach (array_diff($local, $prod) as $t) {
|
||||
echo " - $t" . PHP_EOL;
|
||||
}
|
||||
|
||||
echo PHP_EOL . "=== TABLAS EN AMBAS — diferencias de columnas ===" . PHP_EOL;
|
||||
foreach (array_intersect($prod, $local) as $t) {
|
||||
$pCols = array_column($data['prod_schema'][$t]['columns'], 'Field');
|
||||
$lCols = array_column($data['local_schema'][$t]['columns'], 'Field');
|
||||
$onlyProd = array_diff($pCols, $lCols);
|
||||
$onlyLocal = array_diff($lCols, $pCols);
|
||||
if ($onlyProd || $onlyLocal) {
|
||||
echo " TABLE $t:" . PHP_EOL;
|
||||
foreach ($onlyProd as $c) echo " + prod extra col: $c" . PHP_EOL;
|
||||
foreach ($onlyLocal as $c) echo " - local extra col: $c" . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
echo PHP_EOL . "=== CONTEO DE FILAS PRODUCCIÓN ===" . PHP_EOL;
|
||||
arsort($data['prod_counts']);
|
||||
foreach ($data['prod_counts'] as $t => $n) {
|
||||
echo " $t: $n rows" . PHP_EOL;
|
||||
}
|
||||
@@ -174,10 +174,11 @@ services:
|
||||
ports:
|
||||
- "8082:8081"
|
||||
environment:
|
||||
- REDIS_HOSTS=local:redis:6379
|
||||
# Usa host.docker.internal para alcanzar Redis aunque esté en otra red Docker
|
||||
- REDIS_HOSTS=local:host.docker.internal:6379
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
depends_on:
|
||||
- redis
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- whatsapp-dev-network
|
||||
|
||||
|
||||
@@ -0,0 +1,988 @@
|
||||
<?php
|
||||
/**
|
||||
* enfermero_portal.php — Portal exclusivo para enfermeros.
|
||||
* Muestra solo los domicilios asignados al enfermero autenticado.
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
// Solo enfermeros (o admins visualizando el portal)
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php'); exit;
|
||||
}
|
||||
|
||||
$usuario = $_SESSION['admin_user'];
|
||||
$rol = $usuario['role'] ?? 'admin';
|
||||
$enfId = (int)($usuario['enfermera_id'] ?? 0);
|
||||
|
||||
// Si es admin puede simular ver la agenda de otra enfermera vía ?eid=X
|
||||
if ($rol === 'admin' && isset($_GET['eid'])) {
|
||||
$enfId = (int)$_GET['eid'];
|
||||
}
|
||||
|
||||
if ($rol !== 'admin' && $rol !== 'enfermero') {
|
||||
header('Location: index.php'); exit;
|
||||
}
|
||||
|
||||
$hoy = date('Y-m-d');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<title>Mi Agenda · Lab</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background:#f4f6fb; font-size:.92rem; }
|
||||
|
||||
/* ── Top bar ── */
|
||||
.topbar { background:linear-gradient(135deg,#0d6efd,#0a58ca);
|
||||
color:#fff; padding:.75rem 1rem; position:sticky; top:0; z-index:100; }
|
||||
.topbar .btn-logout { color:rgba(255,255,255,.8); }
|
||||
|
||||
/* ── Fecha selector ── */
|
||||
.date-nav .btn { min-width:36px; }
|
||||
input[type=date].date-input { max-width:160px; border-radius:20px;
|
||||
font-weight:600; color:#0d6efd; }
|
||||
|
||||
/* ── Tarjeta domicilio ── */
|
||||
.domcard { border-left:4px solid #dee2e6; border-radius:8px;
|
||||
background:#fff; box-shadow:0 1px 4px rgba(0,0,0,.07);
|
||||
transition:box-shadow .2s; overflow:hidden; }
|
||||
.domcard:hover { box-shadow:0 3px 10px rgba(0,0,0,.12); }
|
||||
.domcard.estado-programado { border-color:#6c757d; }
|
||||
.domcard.estado-confirmado { border-color:#0d6efd; }
|
||||
.domcard.estado-en_camino { border-color:#fd7e14; }
|
||||
.domcard.estado-en_domicilio { border-color:#20c997; }
|
||||
.domcard.estado-completado { border-color:#198754; opacity:.75; }
|
||||
.domcard.estado-cancelado { border-color:#dc3545; opacity:.6; }
|
||||
|
||||
/* ── Badges estado ── */
|
||||
.badge-programado { background:#6c757d; }
|
||||
.badge-confirmado { background:#0d6efd; }
|
||||
.badge-en_camino { background:#fd7e14; }
|
||||
.badge-en_domicilio { background:#20c997; }
|
||||
.badge-completado { background:#198754; }
|
||||
.badge-cancelado { background:#dc3545; }
|
||||
|
||||
/* ── Botones acción ── */
|
||||
.btn-accion { font-size:.78rem; padding:.28rem .65rem; border-radius:20px; }
|
||||
|
||||
/* ── Servicios extra ── */
|
||||
.se-chip { font-size:.72rem; background:#f0f4ff; border:1px solid #c9d8ff;
|
||||
border-radius:16px; padding:2px 8px; color:#0d6efd; white-space:nowrap; }
|
||||
|
||||
/* ── Empty state ── */
|
||||
.empty-state { color:#adb5bd; text-align:center; padding:3rem 1rem; }
|
||||
|
||||
/* ── FAB Formulario ── */
|
||||
.fab-form { position:fixed; right:1.1rem; bottom:1.4rem; z-index:200;
|
||||
background:#6f42c1; color:#fff; width:52px; height:52px;
|
||||
border-radius:50%; border:none; box-shadow:0 4px 14px rgba(111,66,193,.4);
|
||||
font-size:1.3rem; display:flex; align-items:center; justify-content:center;
|
||||
transition:transform .15s; }
|
||||
.fab-form:hover { transform:scale(1.1); background:#5a32a3; }
|
||||
|
||||
/* ── Modal formulario (bottom-sheet en móvil) ── */
|
||||
@media (max-width:576px) {
|
||||
#modalEnvioForm .modal-dialog { margin:0; position:fixed;
|
||||
bottom:0; left:0; right:0; max-width:100%; }
|
||||
#modalEnvioForm .modal-content { border-radius:1rem 1rem 0 0; }
|
||||
}
|
||||
.form-result-box { background:#f0fff4; border:1px solid #b7ebc8;
|
||||
border-radius:8px; padding:.75rem 1rem; font-size:.85rem; }
|
||||
|
||||
/* ── FAB Nueva Agenda ── */
|
||||
.fab-agenda { position:fixed; right:1.1rem; bottom:4.8rem; z-index:200;
|
||||
background:#0d6efd; color:#fff; width:52px; height:52px;
|
||||
border-radius:50%; border:none; box-shadow:0 4px 14px rgba(13,110,253,.4);
|
||||
font-size:1.3rem; display:flex; align-items:center; justify-content:center;
|
||||
transition:transform .15s; }
|
||||
.fab-agenda:hover { transform:scale(1.1); background:#0a58ca; }
|
||||
|
||||
/* ── Sugerencias paciente ── */
|
||||
.sugerencias-pac { position:absolute; z-index:9999; background:#fff;
|
||||
border:1px solid #dee2e6; border-radius:8px;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,.1); max-height:200px;
|
||||
overflow-y:auto; width:100%; top:100%; left:0; }
|
||||
.sugerencias-pac .item { padding:.5rem .75rem; cursor:pointer;
|
||||
border-bottom:1px solid #f0f0f0; }
|
||||
.sugerencias-pac .item:hover { background:#f0f4ff; }
|
||||
.sugerencias-pac .item:last-child { border-bottom:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Top bar ───────────────────────────────────────────────────── -->
|
||||
<div class="topbar d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="rounded-circle bg-white bg-opacity-25 d-flex align-items-center justify-content-center"
|
||||
style="width:36px;height:36px">
|
||||
<i class="fas fa-user-nurse text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold lh-1" id="enf-nombre">Cargando…</div>
|
||||
<small class="opacity-75">Portal Enfermero · <?= date('d/m/Y') ?></small>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($rol === 'admin'): ?>
|
||||
<a href="lab_agenda_admin.php" class="btn btn-sm btn-light text-primary">
|
||||
<i class="fas fa-tachometer-alt me-1"></i>Admin
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<a href="logout.php" class="btn btn-sm btn-logout">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- ── Navegación de fecha ──────────────────────────────────────── -->
|
||||
<div class="container-fluid px-3 py-2">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2 date-nav">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="portal.cambiarFecha(-1)">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<input type="date" class="form-control form-control-sm date-input text-center"
|
||||
id="portal-fecha" value="<?= $hoy ?>"
|
||||
onchange="portal.cargar()">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="portal.cambiarFecha(1)">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="portal.irHoy()" title="Hoy">
|
||||
<i class="fas fa-calendar-day"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resumen del día -->
|
||||
<div id="portal-resumen" class="d-flex gap-2 mt-2 flex-wrap" style="font-size:.75rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Lista de domicilios ─────────────────────────────────────── -->
|
||||
<div class="container-fluid px-3 pb-4" id="portal-lista">
|
||||
<div class="empty-state">
|
||||
<div style="font-size:2.5rem">📋</div>
|
||||
<p class="mt-2">Cargando agenda…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════ MODAL: Confirmar cancel ══════════════════ -->
|
||||
<div class="modal fade" id="modalCancelar" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white py-2">
|
||||
<h6 class="modal-title mb-0"><i class="fas fa-ban me-1"></i>Cancelar domicilio</h6>
|
||||
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="cancel-dom-id">
|
||||
<label class="form-label small fw-semibold">Motivo de cancelación <span class="text-danger">*</span></label>
|
||||
<textarea class="form-control form-control-sm" id="cancel-notas" rows="3"
|
||||
placeholder="Explica brevemente el motivo…"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Atrás</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="portal.confirmarCancelacion()">
|
||||
<i class="fas fa-ban me-1"></i>Cancelar servicio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════ MODAL: Servicio extra ═══════════════════ -->
|
||||
<div class="modal fade" id="modalServicioExtra" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-purple text-white py-2" style="background:#6f42c1">
|
||||
<h6 class="modal-title mb-0"><i class="fas fa-plus-circle me-1"></i>Servicio adicional</h6>
|
||||
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="se-dom-id">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Tipo <span class="text-danger">*</span></label>
|
||||
<select class="form-select form-select-sm" id="se-tipo">
|
||||
<option value="inyeccion">💉 Inyección</option>
|
||||
<option value="cura">🩹 Cura / Curación</option>
|
||||
<option value="nebulizacion">💨 Nebulización</option>
|
||||
<option value="toma_muestra">🧪 Toma de muestra</option>
|
||||
<option value="tension_arterial">🩺 Tensión arterial</option>
|
||||
<option value="glucometria">🩸 Glucometría</option>
|
||||
<option value="otro">📋 Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Descripción <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control form-control-sm" id="se-descripcion"
|
||||
placeholder="Ej: Ampicilina 500mg IM">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Notas adicionales</label>
|
||||
<textarea class="form-control form-control-sm" id="se-notas" rows="2"
|
||||
placeholder="Observaciones, resultado, indicaciones…"></textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="se-pago">
|
||||
<label class="form-check-label small" for="se-pago">Tiene costo adicional</label>
|
||||
</div>
|
||||
<div id="se-valor-cont" class="mt-2" style="display:none">
|
||||
<input type="number" class="form-control form-control-sm" id="se-valor"
|
||||
placeholder="Valor en pesos" min="0" step="1000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-sm text-white" style="background:#6f42c1"
|
||||
onclick="portal.guardarServicioExtra()">
|
||||
<i class="fas fa-save me-1"></i>Guardar servicio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const ENFERMERA_ID = <?= (int)$enfId ?>;
|
||||
const ROL = '<?= $rol ?>';
|
||||
|
||||
// Etiquetas descriptivas
|
||||
const ESTADOS_LABEL = {
|
||||
programado: { label:'Programado', icon:'🕐', cls:'badge-programado' },
|
||||
confirmado: { label:'Confirmado', icon:'✅', cls:'badge-confirmado' },
|
||||
en_camino: { label:'En camino', icon:'🚗', cls:'badge-en_camino' },
|
||||
en_domicilio: { label:'En domicilio', icon:'🏠', cls:'badge-en_domicilio' },
|
||||
completado: { label:'Completado', icon:'🎉', cls:'badge-completado' },
|
||||
cancelado: { label:'Cancelado', icon:'❌', cls:'badge-cancelado' },
|
||||
};
|
||||
|
||||
// Transiciones que el enfermero puede hacer
|
||||
const TRANSICIONES = {
|
||||
programado: [{ estado:'confirmado', label:'✅ Confirmar con cliente', color:'primary' }],
|
||||
confirmado: [{ estado:'en_camino', label:'🚗 Salir hacia domicilio', color:'warning' }],
|
||||
en_camino: [{ estado:'en_domicilio', label:'🏠 Llegué al domicilio', color:'success' }],
|
||||
en_domicilio: [{ estado:'completado', label:'🎉 Marcar completado', color:'success' }],
|
||||
};
|
||||
|
||||
const portal = {
|
||||
_agenda: [],
|
||||
_cancelModal: null,
|
||||
_seModal: null,
|
||||
|
||||
init() {
|
||||
this._cancelModal = new bootstrap.Modal('#modalCancelar');
|
||||
this._seModal = new bootstrap.Modal('#modalServicioExtra');
|
||||
document.getElementById('se-pago').addEventListener('change', e => {
|
||||
document.getElementById('se-valor-cont').style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
this.cargar();
|
||||
},
|
||||
|
||||
async cargar() {
|
||||
const fecha = document.getElementById('portal-fecha').value;
|
||||
const url = `api/lab/my_agenda.php?fecha=${fecha}` +
|
||||
(ENFERMERA_ID ? `&enfermera_id=${ENFERMERA_ID}` : '');
|
||||
|
||||
document.getElementById('portal-lista').innerHTML =
|
||||
'<div class="empty-state"><div class="spinner-border text-primary"></div><p class="mt-2">Cargando…</p></div>';
|
||||
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error || 'Error al cargar');
|
||||
|
||||
this._agenda = d.agenda || [];
|
||||
|
||||
// Nombre de la enfermera (primer registro o fallback)
|
||||
if (d.agenda?.length) {
|
||||
// enriched by backend, or use session
|
||||
}
|
||||
|
||||
this._renderResumen(d.totales || {}, d.total || 0);
|
||||
this._renderLista();
|
||||
} catch (err) {
|
||||
document.getElementById('portal-lista').innerHTML =
|
||||
`<div class="alert alert-danger mx-2">${esc(err.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
_renderResumen(totales, total) {
|
||||
const res = document.getElementById('portal-resumen');
|
||||
if (!total) { res.innerHTML = ''; return; }
|
||||
const chips = Object.entries(totales).map(([est, n]) => {
|
||||
const info = ESTADOS_LABEL[est] || { label: est, icon:'📌' };
|
||||
return `<span class="badge ${info.cls}" style="font-size:.72rem">
|
||||
${info.icon} ${n} ${info.label}
|
||||
</span>`;
|
||||
});
|
||||
res.innerHTML = chips.join('') + `<span class="text-muted ms-1">Total: <strong>${total}</strong></span>`;
|
||||
},
|
||||
|
||||
_renderLista() {
|
||||
const lista = document.getElementById('portal-lista');
|
||||
if (!this._agenda.length) {
|
||||
lista.innerHTML = `<div class="empty-state">
|
||||
<div style="font-size:3rem">📅</div>
|
||||
<p class="mt-2 fw-semibold">Sin servicios para este día</p>
|
||||
<small>Los servicios asignados aparecerán aquí.</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
lista.innerHTML = this._agenda.map(item => this._cardHTML(item)).join('');
|
||||
},
|
||||
|
||||
_cardHTML(item) {
|
||||
const est = item.domicilio_estado || 'programado';
|
||||
const info = ESTADOS_LABEL[est] || { label: est, icon:'📌', cls:'bg-secondary' };
|
||||
const hora = item.hora_programada ? item.hora_programada.slice(0,5) : '—';
|
||||
const trans = TRANSICIONES[est] || [];
|
||||
const seSuf = (item.servicios_extra || []).map(se =>
|
||||
`<span class="se-chip">${esc(se.descripcion)}</span>`
|
||||
).join(' ');
|
||||
|
||||
const btnsAccion = trans.map(t =>
|
||||
`<button class="btn btn-${t.color} btn-accion"
|
||||
onclick="portal.actualizarEstado(${item.domicilio_id},'${t.estado}')">
|
||||
${t.label}
|
||||
</button>`
|
||||
).join('');
|
||||
|
||||
const btnCancelar = !['completado','cancelado'].includes(est)
|
||||
? `<button class="btn btn-outline-danger btn-accion"
|
||||
onclick="portal.abrirCancelar(${item.domicilio_id})">
|
||||
❌ Cancelar
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
return `<div class="domcard estado-${est} mb-3 p-3" id="card-${item.domicilio_id}">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<span class="badge ${info.cls} text-white">${info.icon} ${info.label}</span>
|
||||
<span class="ms-1 text-muted small">🕐 ${hora}</span>
|
||||
</div>
|
||||
<small class="text-muted">#${item.domicilio_id}</small>
|
||||
</div>
|
||||
|
||||
<div class="fw-bold mb-1">
|
||||
<i class="fas fa-user me-1 text-primary"></i>${esc(item.paciente_nombre || '—')}
|
||||
</div>
|
||||
${item.paciente_telefono
|
||||
? `<a href="tel:${esc(item.paciente_telefono)}" class="d-block small text-muted mb-1">
|
||||
<i class="fas fa-phone me-1"></i>${esc(item.paciente_telefono)}
|
||||
</a>`
|
||||
: ''}
|
||||
|
||||
<div class="small text-secondary mb-1">
|
||||
<i class="fas fa-map-marker-alt me-1 text-danger"></i>${esc(item.direccion || '—')}
|
||||
${item.barrio ? `<span class="text-muted">, ${esc(item.barrio)}</span>` : ''}
|
||||
</div>
|
||||
${item.indicaciones_dir
|
||||
? `<div class="small text-muted mb-1"><i class="fas fa-info-circle me-1"></i>${esc(item.indicaciones_dir)}</div>`
|
||||
: ''}
|
||||
${item.notas_admin
|
||||
? `<div class="small text-info mb-2"><i class="fas fa-sticky-note me-1"></i>${esc(item.notas_admin)}</div>`
|
||||
: ''}
|
||||
|
||||
<!-- Servicios extra registrados -->
|
||||
${seSuf ? `<div class="d-flex flex-wrap gap-1 mb-2">${seSuf}</div>` : ''}
|
||||
|
||||
<!-- Acciones -->
|
||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||
${btnsAccion}
|
||||
${btnCancelar}
|
||||
${!['completado','cancelado'].includes(est)
|
||||
? `<button class="btn btn-outline-secondary btn-accion"
|
||||
onclick="portal.abrirServicioExtra(${item.domicilio_id})">
|
||||
<i class="fas fa-plus me-1"></i>Servicio extra
|
||||
</button>` : ''}
|
||||
${item.paciente_telefono
|
||||
? `<a class="btn btn-outline-success btn-accion"
|
||||
href="https://wa.me/${item.paciente_telefono.replace(/\D/g,'')}" target="_blank">
|
||||
<i class="fab fa-whatsapp"></i>
|
||||
</a>` : ''}
|
||||
<button class="btn btn-outline-secondary btn-accion btn-form-envio"
|
||||
style="color:#6f42c1;border-color:#6f42c1"
|
||||
data-dom-id="${item.domicilio_id}"
|
||||
data-nombre="${esc(item.paciente_nombre||'')}"
|
||||
data-telefono="${esc(item.paciente_telefono||'')}"
|
||||
data-pac-id="${item.paciente_id||0}">
|
||||
<i class="fas fa-file-medical me-1"></i>Formulario
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
// ── Cambiar estado ─────────────────────────────────────────────────────
|
||||
async actualizarEstado(domId, nuevoEstado, notas = '') {
|
||||
try {
|
||||
const r = await fetch('api/lab/update_domicilio_enfermero.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domicilio_id: domId, nuevo_estado: nuevoEstado, notas }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
// Actualizar estado en memoria y re-render
|
||||
const item = this._agenda.find(a => a.domicilio_id == domId);
|
||||
if (item) item.domicilio_estado = nuevoEstado;
|
||||
this._renderLista();
|
||||
this._renderResumen(
|
||||
Object.fromEntries(
|
||||
Object.entries(
|
||||
this._agenda.reduce((a, i) => {
|
||||
a[i.domicilio_estado] = (a[i.domicilio_estado]||0)+1;
|
||||
return a;
|
||||
}, {})
|
||||
)
|
||||
),
|
||||
this._agenda.length
|
||||
);
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Modal cancelar ────────────────────────────────────────────────────
|
||||
abrirCancelar(domId) {
|
||||
document.getElementById('cancel-dom-id').value = domId;
|
||||
document.getElementById('cancel-notas').value = '';
|
||||
this._cancelModal.show();
|
||||
},
|
||||
async confirmarCancelacion() {
|
||||
const domId = +document.getElementById('cancel-dom-id').value;
|
||||
const notas = document.getElementById('cancel-notas').value.trim();
|
||||
if (!notas) { document.getElementById('cancel-notas').focus(); return; }
|
||||
this._cancelModal.hide();
|
||||
await this.actualizarEstado(domId, 'cancelado', notas);
|
||||
},
|
||||
|
||||
// ── Modal servicio extra ──────────────────────────────────────────────
|
||||
abrirServicioExtra(domId) {
|
||||
document.getElementById('se-dom-id').value = domId;
|
||||
document.getElementById('se-tipo').value = 'otro';
|
||||
document.getElementById('se-descripcion').value = '';
|
||||
document.getElementById('se-notas').value = '';
|
||||
document.getElementById('se-pago').checked = false;
|
||||
document.getElementById('se-valor').value = '';
|
||||
document.getElementById('se-valor-cont').style.display = 'none';
|
||||
this._seModal.show();
|
||||
},
|
||||
async guardarServicioExtra() {
|
||||
const domId = +document.getElementById('se-dom-id').value;
|
||||
const desc = document.getElementById('se-descripcion').value.trim();
|
||||
if (!desc) { document.getElementById('se-descripcion').focus(); return; }
|
||||
|
||||
const datos = {
|
||||
domicilio_id: domId,
|
||||
tipo: document.getElementById('se-tipo').value,
|
||||
descripcion: desc,
|
||||
notas: document.getElementById('se-notas').value.trim() || null,
|
||||
requiere_pago:document.getElementById('se-pago').checked ? 1 : 0,
|
||||
valor: document.getElementById('se-pago').checked
|
||||
? parseFloat(document.getElementById('se-valor').value) || null
|
||||
: null,
|
||||
};
|
||||
try {
|
||||
const r = await fetch('api/lab/save_servicio_extra.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(datos),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
this._seModal.hide();
|
||||
// Agregar servicio extra al item en memoria
|
||||
const item = this._agenda.find(a => a.domicilio_id == domId);
|
||||
if (item) {
|
||||
item.servicios_extra = item.servicios_extra || [];
|
||||
item.servicios_extra.push({ descripcion: datos.descripcion, id: d.id });
|
||||
}
|
||||
this._renderLista();
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Navegación ──────────────────────────────────────────────────────
|
||||
cambiarFecha(dias) {
|
||||
const input = document.getElementById('portal-fecha');
|
||||
const d = new Date(input.value + 'T12:00:00'); // evitar offset tz
|
||||
d.setDate(d.getDate() + dias);
|
||||
input.value = d.toISOString().slice(0, 10);
|
||||
this.cargar();
|
||||
},
|
||||
irHoy() {
|
||||
document.getElementById('portal-fecha').value = '<?= $hoy ?>';
|
||||
this.cargar();
|
||||
},
|
||||
};
|
||||
|
||||
// Escapar HTML
|
||||
function esc(s) {
|
||||
return String(s||'').replace(/[<>&"']/g, c =>
|
||||
({ '<':'<','>':'>','&':'&','"':'"',"'":''' }[c]));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
portal.init();
|
||||
agendaNueva.init();
|
||||
formEnvio.cargarPlantillas();
|
||||
|
||||
// Delegated: botones Formulario generados dinámicamente
|
||||
document.getElementById('portal-lista').addEventListener('click', e => {
|
||||
const btn = e.target.closest('.btn-form-envio');
|
||||
if (!btn) return;
|
||||
formEnvio.abrirDesdeCard(
|
||||
+btn.dataset.domId,
|
||||
btn.dataset.nombre || '',
|
||||
btn.dataset.telefono || '',
|
||||
+btn.dataset.pacId || 0
|
||||
);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ══════════════ FAB: Nueva Agenda ══════════════ -->
|
||||
<button class="fab-agenda" title="Agendar nuevo domicilio" onclick="agendaNueva.abrir()">
|
||||
<i class="fas fa-calendar-plus"></i>
|
||||
</button>
|
||||
|
||||
<!-- ══════════════ FAB: Enviar formulario ══════════════ -->
|
||||
<button class="fab-form" title="Enviar formulario a paciente" onclick="formEnvio.abrirLibre()">
|
||||
<i class="fas fa-file-medical"></i>
|
||||
</button>
|
||||
|
||||
<!-- ═══════════════════════ MODAL: Nueva Agenda ══════════════════ -->
|
||||
<div class="modal fade" id="modalNuevaAgenda" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white py-2">
|
||||
<h6 class="modal-title mb-0"><i class="fas fa-calendar-plus me-2"></i>Nueva Agenda de Domicilio</h6>
|
||||
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<!-- Buscar paciente -->
|
||||
<div class="mb-3 position-relative">
|
||||
<label class="form-label small fw-semibold">Paciente <span class="text-danger">*</span></label>
|
||||
<input type="hidden" id="na-paciente-id">
|
||||
<input type="text" id="na-paciente-buscar" class="form-control form-control-sm"
|
||||
placeholder="Buscar por nombre o teléfono…" autocomplete="off">
|
||||
<div id="na-sugerencias" class="sugerencias-pac d-none"></div>
|
||||
<div id="na-paciente-elegido" class="d-none mt-1">
|
||||
<span class="badge bg-success" id="na-paciente-label"></span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 ms-1 text-danger"
|
||||
onclick="agendaNueva.limpiarPaciente()">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dirección -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Dirección <span class="text-danger">*</span></label>
|
||||
<input type="text" id="na-direccion" class="form-control form-control-sm"
|
||||
placeholder="Calle 123 # 45-67">
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Barrio</label>
|
||||
<input type="text" id="na-barrio" class="form-control form-control-sm" placeholder="Barrio">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Ciudad</label>
|
||||
<input type="text" id="na-ciudad" class="form-control form-control-sm" placeholder="Ciudad">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Indicaciones de dirección</label>
|
||||
<input type="text" id="na-indicaciones" class="form-control form-control-sm"
|
||||
placeholder="Casa azul, portón negro…">
|
||||
</div>
|
||||
|
||||
<!-- Fecha y hora -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-7">
|
||||
<label class="form-label small fw-semibold">Fecha <span class="text-danger">*</span></label>
|
||||
<input type="date" id="na-fecha" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label small">Hora</label>
|
||||
<input type="time" id="na-hora" class="form-control form-control-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tipo de servicio -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Tipo de servicio</label>
|
||||
<select id="na-tipo" class="form-select form-select-sm">
|
||||
<option value="">— Sin especificar —</option>
|
||||
<option value="toma_muestras">🧪 Toma de muestras</option>
|
||||
<option value="inyeccion">💉 Inyección</option>
|
||||
<option value="cura">🩹 Cura / Curación</option>
|
||||
<option value="nebulizacion">💨 Nebulización</option>
|
||||
<option value="tension_arterial">🩺 Tensión arterial</option>
|
||||
<option value="glucometria">🩸 Glucometría</option>
|
||||
<option value="otro">📋 Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Notas -->
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Notas / instrucciones</label>
|
||||
<textarea id="na-notas" class="form-control form-control-sm" rows="2"
|
||||
placeholder="Instrucciones adicionales para el servicio…"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="na-error" class="alert alert-danger py-2 d-none small"></div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-primary btn-sm" id="na-btn-guardar" onclick="agendaNueva.guardar()">
|
||||
<i class="fas fa-calendar-check me-1"></i>Agendar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════ MODAL: Enviar Formulario ══════════════ -->
|
||||
<div class="modal fade" id="modalEnvioForm" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header text-white" style="background:#6f42c1">
|
||||
<h6 class="modal-title"><i class="fas fa-file-medical me-2"></i>Enviar Formulario</h6>
|
||||
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<!-- Seleccionar plantilla -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Plantilla de formulario *</label>
|
||||
<select id="fm-plantilla" class="form-select form-select-sm">
|
||||
<option value="">— Seleccionar —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Datos del paciente -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Nombre del paciente</label>
|
||||
<input type="text" id="fm-nombre" class="form-control form-control-sm" placeholder="Nombre completo">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Teléfono (para enviar por WhatsApp)</label>
|
||||
<input type="tel" id="fm-telefono" class="form-control form-control-sm" placeholder="573001234567">
|
||||
<div class="form-text">Ingrese el número con código de país, ej: 573001234567</div>
|
||||
</div>
|
||||
|
||||
<!-- Resultado -->
|
||||
<div id="fm-resultado" class="d-none">
|
||||
<div class="form-result-box">
|
||||
<div class="fw-semibold text-success mb-1">✅ Formulario generado</div>
|
||||
<div class="small mb-2">Comparte este enlace con el paciente:</div>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" id="fm-url" class="form-control" readonly>
|
||||
<button class="btn btn-outline-secondary" onclick="formEnvio.copiarUrl()"><i class="fas fa-copy"></i></button>
|
||||
</div>
|
||||
<a id="fm-wa-link" href="#" target="_blank" class="btn btn-success btn-sm w-100">
|
||||
<i class="fab fa-whatsapp me-1"></i>Abrir en WhatsApp
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
|
||||
<button id="fm-btn-enviar" class="btn btn-sm text-white" style="background:#6f42c1"
|
||||
onclick="formEnvio.enviar()">
|
||||
<i class="fas fa-paper-plane me-1"></i>Generar enlace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// agendaNueva — Agendar domicilio desde el portal enfermero
|
||||
// ═══════════════════════════════════════════════════════
|
||||
const agendaNueva = {
|
||||
_modal: null,
|
||||
_buscarTimer: null,
|
||||
|
||||
init() {
|
||||
this._modal = new bootstrap.Modal('#modalNuevaAgenda');
|
||||
const inp = document.getElementById('na-paciente-buscar');
|
||||
inp.addEventListener('input', () => {
|
||||
clearTimeout(this._buscarTimer);
|
||||
this._buscarTimer = setTimeout(() => this._buscarPacientes(inp.value.trim()), 320);
|
||||
});
|
||||
inp.addEventListener('blur', () => {
|
||||
setTimeout(() => document.getElementById('na-sugerencias').classList.add('d-none'), 200);
|
||||
});
|
||||
},
|
||||
|
||||
abrir() {
|
||||
document.getElementById('na-paciente-id').value = '';
|
||||
document.getElementById('na-paciente-buscar').value = '';
|
||||
document.getElementById('na-direccion').value = '';
|
||||
document.getElementById('na-barrio').value = '';
|
||||
document.getElementById('na-ciudad').value = '';
|
||||
document.getElementById('na-indicaciones').value = '';
|
||||
document.getElementById('na-hora').value = '';
|
||||
document.getElementById('na-tipo').value = '';
|
||||
document.getElementById('na-notas').value = '';
|
||||
document.getElementById('na-fecha').value = document.getElementById('portal-fecha').value;
|
||||
document.getElementById('na-sugerencias').classList.add('d-none');
|
||||
document.getElementById('na-paciente-elegido').classList.add('d-none');
|
||||
document.getElementById('na-paciente-buscar').classList.remove('d-none');
|
||||
document.getElementById('na-error').classList.add('d-none');
|
||||
const btn = document.getElementById('na-btn-guardar');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar';
|
||||
this._modal.show();
|
||||
},
|
||||
|
||||
async _buscarPacientes(q) {
|
||||
if (q.length < 2) {
|
||||
document.getElementById('na-sugerencias').classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(q)}&limit=8`);
|
||||
const d = await r.json();
|
||||
const pac = d.data || d.pacientes || [];
|
||||
const sug = document.getElementById('na-sugerencias');
|
||||
if (!pac.length) { sug.classList.add('d-none'); return; }
|
||||
sug.innerHTML = pac.map(p =>
|
||||
`<div class="item"
|
||||
data-id="${p.id}"
|
||||
data-nombre="${esc(p.nombre_completo || p.nombre || '')}"
|
||||
data-telefono="${esc(p.telefono || '')}"
|
||||
data-direccion="${esc(p.direccion || '')}"
|
||||
data-barrio="${esc(p.barrio || '')}"
|
||||
data-ciudad="${esc(p.ciudad || '')}">
|
||||
<strong>${esc(p.nombre_completo || p.nombre || '')}</strong>
|
||||
${p.telefono ? `<small class="text-muted ms-1">${esc(p.telefono)}</small>` : ''}
|
||||
${p.direccion ? `<br><small class="text-primary"><i class="fas fa-map-marker-alt me-1"></i>${esc(p.direccion)}${p.barrio ? ', '+esc(p.barrio) : ''}</small>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
sug.querySelectorAll('.item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
this._seleccionarPaciente(
|
||||
+el.dataset.id,
|
||||
el.dataset.nombre,
|
||||
el.dataset.telefono,
|
||||
el.dataset.direccion,
|
||||
el.dataset.barrio,
|
||||
el.dataset.ciudad
|
||||
);
|
||||
});
|
||||
});
|
||||
sug.classList.remove('d-none');
|
||||
} catch(e) { /* silencioso */ }
|
||||
},
|
||||
|
||||
_seleccionarPaciente(id, nombre, telefono, direccion, barrio = '', ciudad = '') {
|
||||
document.getElementById('na-paciente-id').value = id;
|
||||
document.getElementById('na-paciente-buscar').value = nombre;
|
||||
document.getElementById('na-paciente-label').textContent =
|
||||
nombre + (telefono ? ` · ${telefono}` : '');
|
||||
document.getElementById('na-paciente-elegido').classList.remove('d-none');
|
||||
document.getElementById('na-sugerencias').classList.add('d-none');
|
||||
// Siempre rellenar la dirección del paciente (editable)
|
||||
if (direccion) document.getElementById('na-direccion').value = direccion;
|
||||
if (barrio) document.getElementById('na-barrio').value = barrio;
|
||||
if (ciudad) document.getElementById('na-ciudad').value = ciudad;
|
||||
if (direccion) document.getElementById('na-direccion').focus();
|
||||
},
|
||||
|
||||
limpiarPaciente() {
|
||||
document.getElementById('na-paciente-id').value = '';
|
||||
document.getElementById('na-paciente-buscar').value = '';
|
||||
document.getElementById('na-paciente-elegido').classList.add('d-none');
|
||||
document.getElementById('na-paciente-buscar').focus();
|
||||
},
|
||||
|
||||
async guardar() {
|
||||
const pacId = +document.getElementById('na-paciente-id').value;
|
||||
const direccion = document.getElementById('na-direccion').value.trim();
|
||||
const fecha = document.getElementById('na-fecha').value;
|
||||
document.getElementById('na-error').classList.add('d-none');
|
||||
|
||||
if (!pacId) { this._mostrarError('Selecciona un paciente de la lista.'); return; }
|
||||
if (!direccion) { this._mostrarError('La dirección es obligatoria.'); return; }
|
||||
if (!fecha) { this._mostrarError('La fecha es obligatoria.'); return; }
|
||||
|
||||
const btn = document.getElementById('na-btn-guardar');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Guardando…';
|
||||
|
||||
const datos = {
|
||||
paciente_id: pacId,
|
||||
direccion,
|
||||
barrio: document.getElementById('na-barrio').value.trim() || null,
|
||||
ciudad: document.getElementById('na-ciudad').value.trim() || null,
|
||||
indicaciones_dir: document.getElementById('na-indicaciones').value.trim() || null,
|
||||
fecha_programada: fecha,
|
||||
hora_programada: document.getElementById('na-hora').value || null,
|
||||
tipo_servicio: document.getElementById('na-tipo').value || null,
|
||||
notas_admin: document.getElementById('na-notas').value.trim() || null,
|
||||
estado: 'programado',
|
||||
...(ENFERMERA_ID ? { enfermera_id: ENFERMERA_ID } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch('api/lab/save_domicilio.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(datos),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error || 'Error al guardar');
|
||||
this._modal.hide();
|
||||
portal.cargar();
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar';
|
||||
this._mostrarError(e.message);
|
||||
}
|
||||
},
|
||||
|
||||
_mostrarError(msg) {
|
||||
const el = document.getElementById('na-error');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('d-none');
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// formEnvio — Lógica de envío de formularios
|
||||
// ═══════════════════════════════════════════════════════
|
||||
const formEnvio = {
|
||||
_plantillas: [],
|
||||
_modalEl: null,
|
||||
_modal: null,
|
||||
_domicilioId: null,
|
||||
_pacienteId: null,
|
||||
|
||||
// ── Obtener instancia Bootstrap Modal ────────────────
|
||||
_getModal() {
|
||||
if (!this._modal) {
|
||||
this._modalEl = document.getElementById('modalEnvioForm');
|
||||
this._modal = bootstrap.Modal.getOrCreateInstance(this._modalEl);
|
||||
}
|
||||
return this._modal;
|
||||
},
|
||||
|
||||
// ── Cargar plantillas al inicio ───────────────────────
|
||||
async cargarPlantillas() {
|
||||
try {
|
||||
const r = await fetch('api/lab/get_formularios.php');
|
||||
const d = await r.json();
|
||||
this._plantillas = d.data || [];
|
||||
const sel = document.getElementById('fm-plantilla');
|
||||
this._plantillas.forEach(f => {
|
||||
const o = document.createElement('option');
|
||||
o.value = f.id;
|
||||
o.textContent = f.nombre + (f.categoria ? ` (${f.categoria})` : '');
|
||||
sel.appendChild(o);
|
||||
});
|
||||
} catch(e) {
|
||||
console.warn('No se pudieron cargar formularios:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Abrir modal desde botón en tarjeta de domicilio ──
|
||||
abrirDesdeCard(domId, nombre, telefono, pacienteId) {
|
||||
this._limpiar();
|
||||
this._domicilioId = domId;
|
||||
this._pacienteId = pacienteId || null;
|
||||
if (nombre) document.getElementById('fm-nombre').value = nombre;
|
||||
if (telefono) document.getElementById('fm-telefono').value = telefono;
|
||||
this._getModal().show();
|
||||
},
|
||||
|
||||
// ── Abrir modal en blanco (FAB) ────────────────────────
|
||||
abrirLibre() {
|
||||
this._limpiar();
|
||||
this._getModal().show();
|
||||
},
|
||||
|
||||
// ── Limpiar estado modal ───────────────────────────────
|
||||
_limpiar() {
|
||||
this._domicilioId = null;
|
||||
this._pacienteId = null;
|
||||
document.getElementById('fm-plantilla').value = '';
|
||||
document.getElementById('fm-nombre').value = '';
|
||||
document.getElementById('fm-telefono').value = '';
|
||||
document.getElementById('fm-resultado').classList.add('d-none');
|
||||
document.getElementById('fm-btn-enviar').disabled = false;
|
||||
},
|
||||
|
||||
// ── Generar y enviar formulario ──────────────────────
|
||||
async enviar() {
|
||||
const plantillaId = parseInt(document.getElementById('fm-plantilla').value);
|
||||
const nombre = document.getElementById('fm-nombre').value.trim();
|
||||
const telefono = document.getElementById('fm-telefono').value.trim();
|
||||
|
||||
if (!plantillaId) { alert('Selecciona una plantilla de formulario.'); return; }
|
||||
|
||||
const btn = document.getElementById('fm-btn-enviar');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
|
||||
try {
|
||||
const body = {
|
||||
formulario_id: plantillaId,
|
||||
domicilio_id: this._domicilioId,
|
||||
paciente_id: this._pacienteId,
|
||||
enviado_via: 'whatsapp',
|
||||
datos_prefilled: {
|
||||
...(nombre ? { nombre_completo: nombre } : {}),
|
||||
...(telefono ? { telefono: telefono } : {}),
|
||||
}
|
||||
};
|
||||
// Si hay paciente_id, dejamos que el servidor enriquezca automáticamente
|
||||
if (!this._pacienteId && nombre) {
|
||||
body.datos_prefilled.__paciente = { nombre_completo: nombre, telefono };
|
||||
}
|
||||
|
||||
const r = await fetch('api/lab/send_formulario.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const res = await r.json();
|
||||
|
||||
if (!res.ok) throw new Error(res.error || 'Error al generar');
|
||||
|
||||
// Mostrar resultado
|
||||
document.getElementById('fm-url').value = res.url;
|
||||
const waLink = document.getElementById('fm-wa-link');
|
||||
waLink.href = res.whatsapp_url || '#';
|
||||
if (!res.whatsapp_url) waLink.style.display = 'none';
|
||||
document.getElementById('fm-resultado').classList.remove('d-none');
|
||||
|
||||
btn.innerHTML = '<i class="fas fa-check me-1"></i>Listo';
|
||||
} catch(e) {
|
||||
alert('Error: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Generar enlace';
|
||||
}
|
||||
},
|
||||
|
||||
// ── Copiar URL al portapapeles ─────────────────────────
|
||||
async copiarUrl() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(document.getElementById('fm-url').value);
|
||||
const btn = document.querySelector('#modalEnvioForm .input-group .btn');
|
||||
btn.innerHTML = '<i class="fas fa-check text-success"></i>';
|
||||
setTimeout(() => btn.innerHTML = '<i class="fas fa-copy"></i>', 1500);
|
||||
} catch(e) { alert('No se pudo copiar'); }
|
||||
},
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,831 @@
|
||||
<?php
|
||||
/**
|
||||
* form_cliente.php — Página pública para que el cliente llene / firme el formulario
|
||||
* NO REQUIERE AUTENTICACIÓN. Acceso via token: ?t=TOKEN
|
||||
*/
|
||||
$token = trim($_GET['t'] ?? '');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Formulario</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
/* ── Base ──────────────────────────────────────── */
|
||||
body { background:#f0f4ff; min-height:100vh; font-size:16px; }
|
||||
.form-wrap { max-width:540px; margin:0 auto; padding:1rem; }
|
||||
|
||||
/* ── Header ────────────────────────────────────── */
|
||||
.form-header { background:linear-gradient(135deg,#1565c0,#0288d1);
|
||||
border-radius:12px 12px 0 0; padding:24px 20px; }
|
||||
|
||||
/* ── Card ───────────────────────────────────────── */
|
||||
.form-card { background:#fff; border-radius:0 0 12px 12px;
|
||||
padding:20px; box-shadow:0 4px 20px rgba(0,0,0,.12); }
|
||||
|
||||
/* ── Linked field (read-only) ───────────────────── */
|
||||
.field-linked { background:#eef3ff; border-color:#93b4f7; }
|
||||
/* ── Inline linked field dentro de párrafo ──────── */
|
||||
.inline-linked {
|
||||
display:inline-block;
|
||||
border:none;
|
||||
border-bottom:1.5px solid #555;
|
||||
background:transparent;
|
||||
min-width:80px;
|
||||
font-size:inherit;
|
||||
line-height:inherit;
|
||||
color:#1a56db;
|
||||
font-weight:600;
|
||||
padding:0 3px;
|
||||
vertical-align:baseline;
|
||||
outline:none;
|
||||
}
|
||||
.inline-linked:empty { min-width:80px; }
|
||||
.inline-linked[data-editable] { border-bottom-color:#1a56db; cursor:text; }
|
||||
/* ── Firma canvas ───────────────────────────────── */
|
||||
#firma-canvas { border:2px solid #1565c0; border-radius:8px;
|
||||
cursor:crosshair; touch-action:none;
|
||||
background:#fff; display:block; width:100%; height:150px; }
|
||||
#firma-canvas.empty { border-style:dashed; border-color:#adb5bd; }
|
||||
|
||||
/* ── Firma modos ─────────────────────────────── */
|
||||
.firma-modo-btns { display:flex; gap:8px; margin-bottom:12px; }
|
||||
.firma-modo-btn { flex:1; padding:7px 0; border:2px solid #dee2e6; border-radius:8px;
|
||||
background:#f8f9fa; cursor:pointer; font-size:.85rem; font-weight:600;
|
||||
color:#6c757d; transition:all .2s; text-align:center; }
|
||||
.firma-modo-btn.activo { border-color:#1565c0; background:#e8f0fe; color:#1565c0; }
|
||||
.firma-foto-drop { border:2px dashed #adb5bd; border-radius:8px; padding:28px 16px;
|
||||
text-align:center; color:#6c757d; cursor:pointer;
|
||||
transition:border-color .2s; background:#fff; display:block; }
|
||||
.firma-foto-drop:hover, .firma-foto-drop.dragover { border-color:#1565c0; color:#1565c0; }
|
||||
#firma-foto-preview { max-width:100%; max-height:160px; border-radius:8px;
|
||||
border:2px solid #1565c0; display:none; margin-top:8px; object-fit:contain; }
|
||||
|
||||
/* ── Estado chips ────────────────────────────────── */
|
||||
.chip-estado { display:inline-flex; align-items:center; gap:6px;
|
||||
padding:6px 14px; border-radius:100px; font-size:.85rem;
|
||||
font-weight:600; }
|
||||
|
||||
/* ── Success overlay ─────────────────────────────── */
|
||||
.check-circle { width:80px; height:80px; border-radius:50%;
|
||||
background:#e8f5e9; display:flex; align-items:center;
|
||||
justify-content:center; margin:0 auto; }
|
||||
@keyframes pop { 0%{transform:scale(0)} 80%{transform:scale(1.15)} 100%{transform:scale(1)} }
|
||||
.check-circle svg { animation:pop .4s ease both; }
|
||||
|
||||
/* ── Error screen ─────────────────────────────────── */
|
||||
|
||||
/* ── Loading screen ──────────────────────────────── */
|
||||
#loading-screen { min-height:50vh; display:flex; flex-direction:column;
|
||||
align-items:center; justify-content:center; gap:12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="form-wrap">
|
||||
|
||||
<!-- ── LOADING ──────────────────────────────────────── -->
|
||||
<div id="loading-screen">
|
||||
<div class="spinner-border text-primary" style="width:3rem;height:3rem;"></div>
|
||||
<p class="text-muted">Cargando formulario…</p>
|
||||
</div>
|
||||
|
||||
<!-- ── ERROR ─────────────────────────────────────────── -->
|
||||
<div id="error-screen" style="display:none">
|
||||
<div class="text-center py-5">
|
||||
<div style="font-size:4rem">😔</div>
|
||||
<h5 id="err-title" class="mt-3 fw-bold text-danger">Formulario no disponible</h5>
|
||||
<p id="err-msg" class="text-muted"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── YA FIRMADO (bloqueado) ───────────────────── -->
|
||||
<div id="ya-firmado-screen" style="display:none">
|
||||
<div class="form-header text-white" style="border-radius:12px 12px 0 0">
|
||||
<!-- Encabezado empresa -->
|
||||
<div id="yf-empresa-header" style="display:none;border-bottom:1px solid rgba(255,255,255,.3);margin-bottom:10px;padding-bottom:10px" class="d-flex align-items-center gap-2">
|
||||
<img id="yf-empresa-logo" src="" alt="" style="max-height:40px;border-radius:5px;background:rgba(255,255,255,.2);padding:3px;display:none">
|
||||
<div>
|
||||
<div id="yf-empresa-nombre" class="fw-bold" style="font-size:.9rem"></div>
|
||||
<div id="yf-empresa-subtitulo" class="opacity-75" style="font-size:.75rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h5 class="fw-bold mb-0 text-center">
|
||||
<i class="fas fa-lock me-2"></i>Formulario ya completado
|
||||
</h5>
|
||||
</div>
|
||||
<div class="form-card text-center">
|
||||
<div class="signed-icon mx-auto mb-3">
|
||||
<i class="fas fa-file-signature" style="font-size:2.5rem;color:#1565c0"></i>
|
||||
</div>
|
||||
<h5 class="fw-bold mb-1" id="yf-titulo">Formulario</h5>
|
||||
<p class="text-muted small mb-3" id="yf-paciente"></p>
|
||||
<span id="yf-estado-badge" class="badge mb-3" style="font-size:.85rem"></span>
|
||||
|
||||
<div id="yf-firma-thumb" style="display:none" class="mb-3">
|
||||
<p class="small text-muted fw-semibold mb-1">Firma registrada:</p>
|
||||
<img id="yf-firma-img" src="" alt="Firma"
|
||||
class="border rounded p-2" style="max-height:120px;max-width:100%">
|
||||
</div>
|
||||
|
||||
<a id="yf-pdf-btn" href="#" target="_blank"
|
||||
class="btn btn-danger w-100 mb-2 fw-semibold">
|
||||
<i class="fas fa-file-pdf me-2"></i>Ver documento firmado (PDF)
|
||||
</a>
|
||||
|
||||
<div id="yf-hash-box" style="display:none" class="mt-3 text-start">
|
||||
<p class="small text-muted mb-1"><i class="fas fa-shield-alt me-1"></i>Sello de integridad SHA-256:</p>
|
||||
<code id="yf-hash" class="d-block" style="font-size:10px;word-break:break-all;
|
||||
background:#f0f5ff;border:1px solid #c3d3f7;border-radius:4px;padding:6px 8px;
|
||||
color:#1e3a6e">—</code>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning border mt-3 text-start small">
|
||||
<i class="fas fa-lock me-1"></i>
|
||||
Este formulario ya fue firmado y <strong>no puede editarse</strong>.
|
||||
Una copia del documento fue registrada con sello digital.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── FORMULARIO ────────────────────────────────────── -->
|
||||
<div id="form-screen" style="display:none">
|
||||
<div class="form-header text-white mb-0" id="form-main-header">
|
||||
<!-- Encabezado empresa (se muestra con config) -->
|
||||
<div id="f-empresa-header" style="display:none;border-bottom:1px solid rgba(255,255,255,.3);margin-bottom:10px;padding-bottom:10px" class="d-flex align-items-center gap-2">
|
||||
<img id="f-empresa-logo" src="" alt="" style="max-height:48px;border-radius:5px;background:rgba(255,255,255,.2);padding:3px;display:none">
|
||||
<div>
|
||||
<div id="f-empresa-nombre" class="fw-bold" style="font-size:.95rem"></div>
|
||||
<div id="f-empresa-subtitulo" class="opacity-75" style="font-size:.78rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<div>
|
||||
<h5 class="fw-bold mb-1" id="f-titulo">Formulario</h5>
|
||||
<p class="mb-0 opacity-75 small" id="f-desc"></p>
|
||||
</div>
|
||||
<div id="f-badge"></div>
|
||||
</div>
|
||||
<div class="mt-3 pt-2 border-top border-white border-opacity-25 small opacity-75" id="f-paciente-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<!-- Nota para el paciente -->
|
||||
<div class="alert alert-info py-2 small mb-3" id="instruccion-nota">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Por favor, completa los campos y firma al final.
|
||||
</div>
|
||||
|
||||
<!-- Campos del formulario -->
|
||||
<form id="form-main" novalidate>
|
||||
<div id="campos-container"></div>
|
||||
|
||||
<!-- ── Firma ─────────────────────────────── -->
|
||||
<div id="firma-container" style="display:none" class="mt-4">
|
||||
<hr>
|
||||
<label class="form-label fw-semibold">
|
||||
<i class="fas fa-signature me-1 text-primary"></i>
|
||||
Firma digital
|
||||
<span id="firma-req-badge" class="badge bg-danger ms-1 small" style="display:none">Requerida</span>
|
||||
</label>
|
||||
|
||||
<!-- área canvas (dibujo) -->
|
||||
<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">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
onclick="firma.limpiarCanvas()">
|
||||
<i class="fas fa-eraser me-1"></i>Limpiar firma
|
||||
</button>
|
||||
<span id="firma-status" class="small text-muted align-self-center">Sin firma</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- área foto -->
|
||||
<div id="firma-foto-area" class="mt-3">
|
||||
<p class="text-muted small mb-2">📷 Adjunta una foto de tu firma o documento.</p>
|
||||
<label class="firma-foto-drop" id="firma-foto-drop" for="firma-foto-input">
|
||||
<i class="fas fa-camera fa-2x d-block mb-2"></i>
|
||||
Toca para abrir cámara o galería
|
||||
</label>
|
||||
<input type="file" id="firma-foto-input" accept="image/*" capture="environment"
|
||||
class="d-none" onchange="firma._onFotoChange(this)">
|
||||
<img id="firma-foto-preview" src="" alt="Vista previa de firma">
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
onclick="firma.limpiarFoto()">
|
||||
<i class="fas fa-eraser me-1"></i>Quitar foto
|
||||
</button>
|
||||
<span id="firma-foto-status" class="small text-muted align-self-center">Sin foto</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Submit ─────────────────────────────── -->
|
||||
<div class="d-grid mt-4">
|
||||
<button type="button" class="btn btn-primary btn-lg fw-bold"
|
||||
id="btn-enviar" onclick="formCliente.enviar()">
|
||||
<i class="fas fa-check-circle me-2"></i>Enviar formulario
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted text-center small mt-2">
|
||||
<i class="fas fa-lock me-1"></i>Tus datos están protegidos
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── ÉXITO ─────────────────────────────────────────── -->
|
||||
<div id="success-screen" style="display:none">
|
||||
<div class="form-header text-white" style="border-radius:12px 12px 0 0">
|
||||
<h5 class="fw-bold mb-0 text-center"><i class="fas fa-check-circle me-2"></i>¡Formulario enviado!</h5>
|
||||
</div>
|
||||
<div class="form-card text-center">
|
||||
<div class="check-circle mt-2 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 52 52">
|
||||
<circle cx="26" cy="26" r="25" fill="none" stroke="#4caf50" stroke-width="2"/>
|
||||
<path fill="none" stroke="#4caf50" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" d="M14 27 l7 7 l17-17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="fw-bold">¡Gracias! 🎉</h5>
|
||||
<p class="text-muted" id="success-msg">Tu respuesta ha sido registrada correctamente.</p>
|
||||
<div id="success-firma-thumb" class="mt-3" style="display:none">
|
||||
<p class="small text-muted fw-semibold">Firma registrada:</p>
|
||||
<img id="success-firma-img" src="" alt="Firma"
|
||||
class="border rounded p-2" style="max-height:120px;max-width:100%">
|
||||
</div>
|
||||
|
||||
<!-- Link PDF -->
|
||||
<div id="success-pdf-row" style="display:none" class="mt-3">
|
||||
<a id="success-pdf-btn" href="#" target="_blank"
|
||||
class="btn btn-danger w-100 fw-semibold">
|
||||
<i class="fas fa-file-pdf me-2"></i>Descargar documento firmado (PDF)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Hash SHA-256 -->
|
||||
<div id="success-hash-box" style="display:none" class="mt-3 text-start">
|
||||
<p class="small text-muted mb-1 fw-semibold">
|
||||
<i class="fas fa-shield-alt me-1 text-success"></i>Sello de integridad del documento:
|
||||
</p>
|
||||
<code id="success-hash" class="d-block" style="font-size:10px;word-break:break-all;
|
||||
background:#f0f5ff;border:1px solid #c3d3f7;border-radius:4px;padding:6px 8px;
|
||||
color:#1e3a6e"></code>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-light border mt-4 text-start small">
|
||||
<i class="fas fa-clock me-1 text-muted"></i>
|
||||
Tu información ha sido recibida y está en manos del equipo médico.
|
||||
Puedes cerrar esta página.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// CONFIG
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const TOKEN = <?= json_encode($token) ?>;
|
||||
const API = 'api/lab/submit_formulario.php';
|
||||
|
||||
let _data = null; // respuesta de verificar token
|
||||
let _cfg = {}; // config de diseño del laboratorio
|
||||
let _firmaDibujada = false;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// HELPERS
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const $ = id => document.getElementById(id);
|
||||
function esc(s) {
|
||||
return String(s||'').replace(/[<>&"']/g, c=>
|
||||
({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c])
|
||||
);
|
||||
}
|
||||
function show(id) { $(id).style.display = ''; }
|
||||
function hide(id) { $(id).style.display = 'none'; }
|
||||
function showBlock(id) { $(id).style.display = 'block'; }
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// FIRMA DIGITAL
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const firma = (() => {
|
||||
let canvas, ctx, drawing = false, lastX = 0, lastY = 0;
|
||||
|
||||
function init() {
|
||||
canvas = $('firma-canvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
// Escalar para retina
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.offsetWidth * ratio;
|
||||
canvas.height = canvas.offsetHeight * ratio;
|
||||
ctx.scale(ratio, ratio);
|
||||
ctx.strokeStyle = '#1a1a2e';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
const getPos = e => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (e.touches) {
|
||||
const scX = canvas.offsetWidth / rect.width;
|
||||
const scY = canvas.offsetHeight / rect.height;
|
||||
return {
|
||||
x: (e.touches[0].clientX - rect.left) * scX,
|
||||
y: (e.touches[0].clientY - rect.top) * scY,
|
||||
};
|
||||
}
|
||||
return { x: e.offsetX, y: e.offsetY };
|
||||
};
|
||||
|
||||
// Mouse
|
||||
canvas.addEventListener('mousedown', e => { drawing = true; const p=getPos(e); lastX=p.x; lastY=p.y; });
|
||||
canvas.addEventListener('mousemove', e => { if (!drawing) return; trazo(getPos(e)); });
|
||||
canvas.addEventListener('mouseup', ()=> { drawing = false; ctx.beginPath(); });
|
||||
canvas.addEventListener('mouseleave', ()=> { drawing = false; ctx.beginPath(); });
|
||||
|
||||
// Touch
|
||||
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing=true; const p=getPos(e); lastX=p.x; lastY=p.y; }, {passive:false});
|
||||
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!drawing) return; trazo(getPos(e)); }, {passive:false});
|
||||
canvas.addEventListener('touchend', ()=> { drawing=false; ctx.beginPath(); });
|
||||
}
|
||||
|
||||
function trazo(p) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lastX, lastY);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
lastX = p.x; lastY = p.y;
|
||||
_firmaDibujada = true;
|
||||
canvas.classList.remove('empty');
|
||||
$('firma-status').textContent = '✅ Firma lista';
|
||||
$('firma-status').className = 'small text-success align-self-center fw-semibold';
|
||||
}
|
||||
|
||||
let _fotoDato = null;
|
||||
|
||||
// setModos: controla qué secciones se muestran (sin tabs exclusivos)
|
||||
function setModos(modos) {
|
||||
const m = Array.isArray(modos) && modos.length ? modos : ['canvas','foto'];
|
||||
$('firma-canvas-area').style.display = m.includes('canvas') ? '' : 'none';
|
||||
$('firma-foto-area').style.display = m.includes('foto') ? '' : 'none';
|
||||
}
|
||||
|
||||
function _onFotoChange(input) {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
_fotoDato = e.target.result;
|
||||
const preview = $('firma-foto-preview');
|
||||
preview.src = _fotoDato;
|
||||
preview.style.display = 'block';
|
||||
$('firma-foto-drop').style.display = 'none';
|
||||
$('firma-foto-status').textContent = '✅ Foto lista';
|
||||
$('firma-foto-status').className = 'small text-success align-self-center fw-semibold';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function limpiarCanvas() {
|
||||
ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight);
|
||||
_firmaDibujada = false;
|
||||
canvas.classList.add('empty');
|
||||
$('firma-status').textContent = 'Sin firma';
|
||||
$('firma-status').className = 'small text-muted align-self-center';
|
||||
}
|
||||
|
||||
function limpiarFoto() {
|
||||
_fotoDato = null;
|
||||
$('firma-foto-preview').src = '';
|
||||
$('firma-foto-preview').style.display = 'none';
|
||||
$('firma-foto-drop').style.display = 'block';
|
||||
$('firma-foto-status').textContent = 'Sin foto';
|
||||
$('firma-foto-status').className = 'small text-muted align-self-center';
|
||||
$('firma-foto-input').value = '';
|
||||
}
|
||||
|
||||
function obtenerSVG() { if (!_firmaDibujada) return null; return canvas.toDataURL('image/png'); }
|
||||
function obtenerFoto() { return _fotoDato || null; }
|
||||
|
||||
return { init, limpiarCanvas, limpiarFoto, obtenerSVG, obtenerFoto, setModos, _onFotoChange };
|
||||
})();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// RENDER CAMPOS
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
function renderCampos(esquema, prefilled) {
|
||||
const cont = $('campos-container');
|
||||
let html = '';
|
||||
const pad = prefilled || {};
|
||||
|
||||
esquema.forEach(c => {
|
||||
if (c.tipo === 'separador') {
|
||||
html += `<div class="mt-4 mb-2">
|
||||
<p class="fw-bold text-secondary small text-uppercase mb-0">${esc(c.label)}</p>
|
||||
<hr class="mt-1 mb-3">
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
if (c.tipo === 'firma') return; // se maneja aparte
|
||||
if (c.tipo === 'parrafo') {
|
||||
const ws = c.flujoLibre ? 'normal' : 'pre-wrap';
|
||||
html += `<div class="mb-3" style="font-size:.88rem;line-height:1.75;color:#222;text-align:justify;white-space:${ws}">${esc(c.contenido||'')}</div>`;
|
||||
return;
|
||||
}
|
||||
if (c.tipo === 'parrafo_inline') {
|
||||
const renderLine = line => line.split(/(\{[a-z_]+\})/g).map((p, i) => {
|
||||
if (i % 2 === 1) {
|
||||
const key = p.slice(1,-1);
|
||||
const val = pad['__paciente']?.[key] || pad[key] || '';
|
||||
const hasVal = val.trim() !== '';
|
||||
const w = Math.max(80, val.length * 9 + 24);
|
||||
if (hasVal) {
|
||||
return `<input type="text" class="inline-linked" readonly name="${c.id}_${key}" value="${esc(val)}" style="min-width:${w}px">`;
|
||||
} else {
|
||||
return `<input type="text" class="inline-linked" data-editable name="${c.id}_${key}" placeholder="${esc(key.replace(/_/g,' '))}" style="min-width:${w}px">`;
|
||||
}
|
||||
}
|
||||
return esc(p);
|
||||
}).join('');
|
||||
const inlineHtml = (c.contenido||'').split('\n').map(renderLine).join('<br>');
|
||||
html += `<div class="mb-3" style="font-size:.88rem;line-height:2.4;color:#222;text-align:justify">${inlineHtml}</div>`;
|
||||
return;
|
||||
}
|
||||
if (c.tipo === 'lista_marcable') {
|
||||
const star = c.required ? '<span class="text-danger ms-1">*</span>' : '';
|
||||
const lbl = `<label class="form-label small fw-semibold mb-1">${esc(c.label)}${star}</label>`;
|
||||
const opts = (c.items||[]).map((it, i) => `
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="${c.id}" value="${esc(it)}">
|
||||
<label class="form-check-label">${i+1}. ${esc(it)}</label>
|
||||
</div>`).join('');
|
||||
html += `<div class="mb-3">${lbl}${opts}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const req = c.required ? 'required' : '';
|
||||
const star = c.required ? '<span class="text-danger ms-1">*</span>' : '';
|
||||
|
||||
// Valor pre-llenado (linked usa __paciente.xxx o key directo)
|
||||
let val = '';
|
||||
if (c.tipo === 'linked') {
|
||||
val = pad['__paciente']?.[c.linked_key] || pad[c.linked_key] || '';
|
||||
} else {
|
||||
val = pad[c.id] || '';
|
||||
}
|
||||
|
||||
const isLinked = c.tipo === 'linked';
|
||||
const isReadOnly = isLinked;
|
||||
const linkedNote = isLinked
|
||||
? '<small class="text-info d-block mt-1"><i class="fas fa-link me-1"></i>Campo autocompletado</small>' : '';
|
||||
|
||||
const lbl = `<label class="form-label small fw-semibold mb-1">${esc(c.label)}${star}</label>`;
|
||||
|
||||
if (c.tipo === 'textarea') {
|
||||
html += `<div class="mb-3">${lbl}
|
||||
<textarea class="form-control ${isReadOnly?'field-linked':''}"
|
||||
name="${c.id}" rows="3" ${req} ${isReadOnly?'readonly':''}
|
||||
placeholder="${esc(c.placeholder||'')}">${esc(val)}</textarea>${linkedNote}
|
||||
</div>`;
|
||||
} else if (c.tipo === 'select') {
|
||||
const opts = (c.options||[]).map(o =>
|
||||
`<option value="${esc(o)}" ${o===val?'selected':''}>${esc(o)}</option>`
|
||||
).join('');
|
||||
html += `<div class="mb-3">${lbl}
|
||||
<select class="form-select ${isReadOnly?'field-linked':''}"
|
||||
name="${c.id}" ${req} ${isReadOnly?'disabled':''}>
|
||||
<option value="">— seleccionar —</option>${opts}
|
||||
</select>${linkedNote}
|
||||
</div>`;
|
||||
} else if (c.tipo === 'radio') {
|
||||
const opts = (c.options||[]).map(o => `
|
||||
<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 += `<div class="mb-3">${lbl}${opts}${linkedNote}</div>`;
|
||||
} else if (c.tipo === 'checkbox') {
|
||||
const vals = Array.isArray(val) ? val : [];
|
||||
const opts = (c.options||[]).map(o => `
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="${c.id}" value="${esc(o)}"
|
||||
${vals.includes(o)?'checked':''} ${isReadOnly?'disabled':''}>
|
||||
<label class="form-check-label">${esc(o)}</label>
|
||||
</div>`).join('');
|
||||
html += `<div class="mb-3">${lbl}${opts}${linkedNote}</div>`;
|
||||
} else {
|
||||
// texto, numero, fecha, hora, linked
|
||||
const t = c.tipo === 'numero' ? 'number'
|
||||
: (c.tipo === 'fecha' || c.linked_key === 'fecha_nacimiento') ? 'date'
|
||||
: c.tipo === 'hora' ? 'time' : 'text';
|
||||
html += `<div class="mb-3">${lbl}
|
||||
<input type="${t}" class="form-control ${isReadOnly?'field-linked':''}"
|
||||
name="${c.id}" value="${esc(val)}" ${req} ${isReadOnly?'readonly':''}
|
||||
placeholder="${esc(c.placeholder||'')}">
|
||||
${linkedNote}
|
||||
</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
cont.innerHTML = html;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// formCliente
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const formCliente = {
|
||||
|
||||
async cargar() {
|
||||
if (!TOKEN) {
|
||||
$('err-title').textContent = 'Token inválido';
|
||||
$('err-msg').textContent = 'El enlace no es válido o ha expirado.';
|
||||
hide('loading-screen'); show('error-screen'); return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`${API}?t=${encodeURIComponent(TOKEN)}`);
|
||||
const d = await r.json();
|
||||
if (!d.success) {
|
||||
$('err-title').textContent = d.error || 'No disponible';
|
||||
$('err-msg').textContent = d.detalle || 'El formulario no está disponible.';
|
||||
hide('loading-screen'); show('error-screen'); return;
|
||||
}
|
||||
_data = d;
|
||||
_cfg = d.config || {};
|
||||
|
||||
// Si ya fue firmado/completado: mostrar pantalla bloqueada
|
||||
if (['firmado','completado'].includes(d.envio.estado)) {
|
||||
this._mostrarYaFirmado(d);
|
||||
return;
|
||||
}
|
||||
|
||||
this._renderForm(d);
|
||||
} catch(e) {
|
||||
$('err-title').textContent = 'Error de conexión';
|
||||
$('err-msg').textContent = 'No pudimos cargar el formulario. Intenta de nuevo.';
|
||||
hide('loading-screen'); show('error-screen');
|
||||
}
|
||||
},
|
||||
|
||||
_aplicarDiseño(prefixId) {
|
||||
const cfg = _cfg;
|
||||
// Color del header
|
||||
if (cfg.doc_color) {
|
||||
document.querySelectorAll('.form-header').forEach(h => h.style.background = cfg.doc_color);
|
||||
const canvas = document.getElementById('firma-canvas');
|
||||
if (canvas) canvas.style.borderColor = cfg.doc_color;
|
||||
}
|
||||
// Nombr empresa, logo
|
||||
const hdr = $(prefixId + '-empresa-header');
|
||||
if (hdr && cfg.doc_encabezado) {
|
||||
$(prefixId + '-empresa-nombre').textContent = cfg.doc_encabezado;
|
||||
$(prefixId + '-empresa-subtitulo').textContent = cfg.doc_subtitulo || '';
|
||||
if (cfg.doc_logo_base64) {
|
||||
const img = $(prefixId + '-empresa-logo');
|
||||
img.src = cfg.doc_logo_base64;
|
||||
img.style.display = '';
|
||||
}
|
||||
hdr.style.display = '';
|
||||
}
|
||||
},
|
||||
|
||||
_mostrarYaFirmado(d) {
|
||||
this._aplicarDiseño('yf');
|
||||
const env = d.envio;
|
||||
const form = d.formulario;
|
||||
$('yf-titulo').textContent = form.nombre || 'Formulario';
|
||||
|
||||
const pac = (d.prefilled || {})['__paciente'];
|
||||
if (pac?.nombre_completo) {
|
||||
$('yf-paciente').textContent = pac.nombre_completo +
|
||||
(pac.numero_documento ? ' · ' + pac.numero_documento : '');
|
||||
}
|
||||
|
||||
const badge = $('yf-estado-badge');
|
||||
if (env.estado === 'firmado') {
|
||||
badge.textContent = '✍️ Firmado digitalmente';
|
||||
badge.style.background = '#198754';
|
||||
badge.style.color = '#fff';
|
||||
} else {
|
||||
badge.textContent = '✅ Completado';
|
||||
badge.style.background = '#0d6efd';
|
||||
badge.style.color = '#fff';
|
||||
}
|
||||
|
||||
if (env.firma_svg) {
|
||||
$('yf-firma-img').src = env.firma_svg;
|
||||
show('yf-firma-thumb');
|
||||
}
|
||||
|
||||
// Link al PDF (requiere sesión admin, se muestra de todas formas)
|
||||
$('yf-pdf-btn').href = 'ver_formulario_enviado.php?id=' + env.id;
|
||||
|
||||
if (env.hash_verificacion) {
|
||||
$('yf-hash').textContent = env.hash_verificacion;
|
||||
show('yf-hash-box');
|
||||
}
|
||||
|
||||
hide('loading-screen');
|
||||
show('ya-firmado-screen');
|
||||
},
|
||||
|
||||
_renderForm(d) {
|
||||
const form = d.formulario;
|
||||
const env = d.envio;
|
||||
const prefilled = d.prefilled || {};
|
||||
|
||||
// Diseño doc (color, logo, empresa)
|
||||
this._aplicarDiseño('f');
|
||||
|
||||
// Header
|
||||
$('f-titulo').textContent = form.nombre;
|
||||
$('f-desc').textContent = form.descripcion || '';
|
||||
|
||||
// Info paciente
|
||||
const pac = prefilled['__paciente'];
|
||||
if (pac?.nombre_completo) {
|
||||
$('f-paciente-info').innerHTML =
|
||||
`<i class="fas fa-user me-1"></i>${esc(pac.nombre_completo)}` +
|
||||
(pac.numero_documento ? ` · ${esc(pac.numero_documento)}` : '');
|
||||
} else {
|
||||
hide('f-paciente-info');
|
||||
}
|
||||
|
||||
// Si ya está completado/firmado
|
||||
if (['completado','firmado'].includes(env.estado)) {
|
||||
$('instruccion-nota').innerHTML =
|
||||
`<i class="fas fa-check-circle text-success me-1"></i>
|
||||
Este formulario ya fue <strong>completado</strong>. Puedes ver tus respuestas abajo.`;
|
||||
$('btn-enviar').disabled = true;
|
||||
$('btn-enviar').textContent = 'Ya enviado';
|
||||
|
||||
// Mostrar firma almacenada si existe
|
||||
if (env.firma_svg) {
|
||||
show('firma-container');
|
||||
const canvas = $('firma-canvas');
|
||||
if (canvas) canvas.style.display = 'none';
|
||||
// Insertar imagen de la firma almacenada
|
||||
const existingImg = document.getElementById('_firma-guardada');
|
||||
if (!existingImg) {
|
||||
const img = document.createElement('img');
|
||||
img.id = '_firma-guardada';
|
||||
img.src = env.firma_svg;
|
||||
img.alt = 'Firma';
|
||||
img.className = 'border rounded p-2 mt-2';
|
||||
img.style.cssText = 'max-height:140px;max-width:100%;display:block;margin:0 auto';
|
||||
canvas.parentNode.insertBefore(img, canvas.nextSibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Campos
|
||||
renderCampos(form.esquema_decoded, prefilled);
|
||||
|
||||
// Firma (se inicializa DESPUÉS de mostrar el form para que offsetWidth sea correcto)
|
||||
if (form.permite_firma) {
|
||||
show('firma-container');
|
||||
if (form.requiere_firma) show('firma-req-badge');
|
||||
// Configurar modos según el campo firma del esquema
|
||||
const firmaCampo = form.esquema_decoded.find(c => c.tipo === 'firma');
|
||||
firma.setModos(firmaCampo?.modos);
|
||||
}
|
||||
|
||||
hide('loading-screen');
|
||||
show('form-screen');
|
||||
|
||||
if (form.permite_firma) {
|
||||
// Pequeño delay para asegurar que el layout ya renderizó
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => firma.init()));
|
||||
}
|
||||
},
|
||||
|
||||
_recopilar() {
|
||||
const datos = {};
|
||||
const form = document.getElementById('form-main');
|
||||
const campos = _data.formulario.esquema_decoded;
|
||||
|
||||
campos.forEach(c => {
|
||||
if (c.tipo === 'separador' || c.tipo === 'firma' || c.tipo === 'parrafo') return;
|
||||
if (c.tipo === 'linked') { return; } // linked viene del prefilled
|
||||
|
||||
// parrafo_inline: recolectar solo los inputs editables (los que el usuario llenó)
|
||||
if (c.tipo === 'parrafo_inline') {
|
||||
form.querySelectorAll(`input[data-editable][name^="${c.id}_"]`).forEach(inp => {
|
||||
const key = inp.name.slice(c.id.length + 1);
|
||||
if (inp.value.trim()) datos[key] = inp.value.trim();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (c.tipo === 'checkbox' || c.tipo === 'lista_marcable') {
|
||||
const checks = [...form.querySelectorAll(`input[name="${c.id}"]:checked`)];
|
||||
datos[c.id] = checks.map(el => el.value);
|
||||
} else if (c.tipo === 'radio') {
|
||||
const r = form.querySelector(`input[name="${c.id}"]:checked`);
|
||||
datos[c.id] = r ? r.value : '';
|
||||
} else {
|
||||
const el = form.querySelector(`[name="${c.id}"]`);
|
||||
datos[c.id] = el ? el.value : '';
|
||||
}
|
||||
});
|
||||
return datos;
|
||||
},
|
||||
|
||||
_validar(datos) {
|
||||
const campos = _data.formulario.esquema_decoded;
|
||||
for (const c of campos) {
|
||||
if (!c.required) continue;
|
||||
if (c.tipo === 'separador' || c.tipo === 'linked' || c.tipo === 'firma' || c.tipo === 'parrafo' || c.tipo === 'parrafo_inline') continue;
|
||||
const v = datos[c.id];
|
||||
const vacio = Array.isArray(v) ? v.length === 0 : !v || !String(v).trim();
|
||||
if (vacio) {
|
||||
const el = document.querySelector(`[name="${c.id}"]`);
|
||||
if (el) { el.focus(); el.scrollIntoView({block:'center', behavior:'smooth'}); }
|
||||
return `El campo "${c.label}" es obligatorio.`;
|
||||
}
|
||||
}
|
||||
if (_data.formulario.requiere_firma && !firma.obtenerSVG() && !firma.obtenerFoto()) {
|
||||
$('firma-container').scrollIntoView({block:'center', behavior:'smooth'});
|
||||
return 'La firma es obligatoria en este formulario.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
async enviar() {
|
||||
const datos = this._recopilar();
|
||||
const err = this._validar(datos);
|
||||
if (err) {
|
||||
alert(err); return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
token: TOKEN,
|
||||
datos_cliente: datos,
|
||||
firma_svg: firma.obtenerSVG() || null,
|
||||
firma_foto: firma.obtenerFoto() || null,
|
||||
};
|
||||
|
||||
const btn = $('btn-enviar');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Enviando…';
|
||||
|
||||
try {
|
||||
const r = await fetch(API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
this._mostrarExito(d, payload.firma_svg);
|
||||
} else {
|
||||
alert(d.error || 'Error al enviar. Intenta de nuevo.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-check-circle me-2"></i>Enviar formulario';
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Error de conexión. Revisa tu internet e intenta de nuevo.');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-check-circle me-2"></i>Enviar formulario';
|
||||
}
|
||||
},
|
||||
|
||||
_mostrarExito(d, firmaSvg) {
|
||||
hide('form-screen');
|
||||
const msg = d.firmado
|
||||
? '¡Tu formulario fue firmado y enviado exitosamente!'
|
||||
: 'Tus respuestas fueron registradas correctamente.';
|
||||
$('success-msg').textContent = msg;
|
||||
if (firmaSvg) {
|
||||
$('success-firma-img').src = firmaSvg;
|
||||
$('success-firma-thumb').style.display = '';
|
||||
}
|
||||
// Hash de verificación
|
||||
if (d.hash) {
|
||||
$('success-hash').textContent = d.hash;
|
||||
show('success-hash-box');
|
||||
}
|
||||
// Link al PDF
|
||||
if (d.envio_id) {
|
||||
const pdfBtn = $('success-pdf-btn');
|
||||
pdfBtn.href = 'ver_formulario_enviado.php?id=' + d.envio_id;
|
||||
show('success-pdf-row');
|
||||
}
|
||||
show('success-screen');
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
},
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// INIT
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
document.addEventListener('DOMContentLoaded', () => formCliente.cargar());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -15,10 +15,15 @@ if (!isUserLoggedIn()) {
|
||||
|
||||
// Verificar conexión a la base de datos
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$dbStatus = 'Conectado';
|
||||
$db = Database::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
$pdo->query('SELECT 1'); // prueba real
|
||||
$dbHost = DB_HOST ?? 'desconocido';
|
||||
$serverIp = $_SERVER['SERVER_ADDR'] ?? gethostbyname(gethostname());
|
||||
$dbStatus = '<span class="text-success fw-bold">Conectado</span> <small class="text-muted">(' . htmlspecialchars($dbHost) . ')</small>';
|
||||
} catch (Exception $e) {
|
||||
$dbStatus = 'Error: ' . $e->getMessage();
|
||||
$dbStatus = '<span class="text-danger fw-bold">Error:</span> ' . htmlspecialchars($e->getMessage());
|
||||
$serverIp = $_SERVER['SERVER_ADDR'] ?? '—';
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
@@ -44,7 +49,7 @@ try {
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="#dashboard" class="nav-link active" data-tab="dashboard"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="#conversations" class="nav-link" data-tab="conversations"><i class="fas fa-comments"></i> Conversaciones <span id="conversations-count" class="sidebar-badge badge bg-danger" style="display:none; font-size:0.72rem; padding:0.15rem 0.4rem;">0</span></a></li>
|
||||
<li><a href="#conversations" class="nav-link" data-tab="conversations"><i class="fas fa-comments"></i> Conversaciones</a></li>
|
||||
<li><a href="#users" class="nav-link" data-tab="users"><i class="fas fa-users"></i> Usuarios</a></li>
|
||||
<li><a href="#menus" class="nav-link" data-tab="menus"><i class="fas fa-list"></i> Menús</a></li>
|
||||
<li><a href="#admin_users" class="nav-link" data-tab="admin_users"><i class="fas fa-user-shield"></i> Usuarios del Sistema</a></li>
|
||||
@@ -55,6 +60,9 @@ try {
|
||||
<li><a href="#autoresponses" class="nav-link" data-tab="autoresponses"><i class="fas fa-robot"></i> Respuestas Auto</a></li>
|
||||
<li><a href="#system_config" class="nav-link" data-tab="system_config"><i class="fas fa-cog"></i> Configuración</a></li>
|
||||
<li><a href="#logs" class="nav-link" data-tab="logs"><i class="fas fa-file-alt"></i> Logs</a></li>
|
||||
<li><a href="#terms" class="nav-link" data-tab="terms"><i class="fas fa-file-contract"></i> T&C Aceptaciones</a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="lab_dashboard.php" class="nav-link text-warning fw-semibold"><i class="fas fa-flask"></i> Módulo Laboratorio <span class="badge bg-warning text-dark ms-1" style="font-size:.65rem">LAB</span></a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Está seguro que desea cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
@@ -63,8 +71,7 @@ try {
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
|
||||
<!-- Notification toasts container (global) -->
|
||||
<div id="global-notification-toasts" style="position:fixed; top:12px; right:12px; z-index:9999;"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="content-header">
|
||||
<div>
|
||||
@@ -89,12 +96,6 @@ try {
|
||||
<div class="status-badge">
|
||||
<span class="status-dot status-online"></span>
|
||||
<span class="status-text">En línea</span>
|
||||
</div> <!-- Notification bell -->
|
||||
<div class="me-2">
|
||||
<button id="notification-bell" class="btn btn-light position-relative" title="Notificaciones">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span id="notification-count" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger" style="display:none;">0</span>
|
||||
</button>
|
||||
</div> <button class="btn btn-primary me-2" onclick="refreshData()">
|
||||
<i class="fas fa-sync-alt"></i> Actualizar
|
||||
</button>
|
||||
@@ -312,7 +313,7 @@ try {
|
||||
</h5>
|
||||
<small class="text-white-50">Administra tus chats de WhatsApp</small>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="col-auto d-flex align-items-center gap-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-0">
|
||||
<i class="fas fa-search"></i>
|
||||
@@ -321,6 +322,9 @@ try {
|
||||
placeholder="Buscar conversaciones..."
|
||||
id="search-conversations">
|
||||
</div>
|
||||
<button class="btn btn-light btn-sm text-nowrap" onclick="exportConversations()" title="Exportar todas las conversaciones a CSV">
|
||||
<i class="fas fa-file-csv"></i> Exportar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -417,40 +421,17 @@ try {
|
||||
|
||||
<!-- Admin Users Tab -->
|
||||
<div id="admin_users" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5><i class="fas fa-user-shield"></i> Usuarios del Sistema</h5>
|
||||
<button class="btn btn-primary" onclick="showCreateAdminUserModal()">
|
||||
<i class="fas fa-plus"></i> Nuevo Usuario
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Gestiona los usuarios administradores que tienen acceso al sistema.
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Usuario</th>
|
||||
<th>Nombre Completo</th>
|
||||
<th>Email</th>
|
||||
<th>Estado</th>
|
||||
<th>Último Acceso</th>
|
||||
<th>Fecha Creación</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-users-table">
|
||||
<tr>
|
||||
<td colspan="7" class="text-center">
|
||||
<i class="fas fa-spinner fa-spin"></i> Cargando usuarios...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="fas fa-users-cog fa-3x text-primary mb-3"></i>
|
||||
<h5 class="fw-semibold mb-2">Gestión de Usuarios & Roles</h5>
|
||||
<p class="text-muted mb-4">
|
||||
La administración de usuarios, roles y permisos de módulo<br>
|
||||
se encuentra centralizada en el Módulo de Laboratorio.
|
||||
</p>
|
||||
<a href="lab_usuarios.php" class="btn btn-primary px-4">
|
||||
<i class="fas fa-external-link-alt me-2"></i>Ir a Usuarios & Roles
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -802,8 +783,71 @@ try {
|
||||
<label class="form-label">Mensaje de Bienvenida</label>
|
||||
<textarea class="form-control" id="welcome-message" rows="3" placeholder="Mensaje que se envía a usuarios nuevos"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="fas fa-file-contract me-1 text-primary"></i>Mensaje de Términos y Condiciones
|
||||
</label>
|
||||
<textarea class="form-control" id="terms-message" rows="4"
|
||||
placeholder="Mensaje de aceptación de términos y condiciones. Se muestra antes de cualquier interacción."></textarea>
|
||||
<small class="form-text text-muted">Este mensaje se muestra al usuario antes de aceptar cualquier cosa. Incluye las opciones de aceptar o rechazar.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="fas fa-ban me-1 text-danger"></i>Mensaje cuando rechaza los términos
|
||||
</label>
|
||||
<textarea class="form-control" id="terms-rejected-message" rows="3"
|
||||
placeholder="Mensaje que se envía cuando el usuario rechaza los términos y condiciones."></textarea>
|
||||
<small class="form-text text-muted">Se envía automáticamente si el usuario elige no aceptar.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── GESTIÓN DOCUMENTO PDF TÉRMINOS ─────────────────────────── -->
|
||||
<div class="card border-0 bg-light mb-4">
|
||||
<div class="card-body p-3">
|
||||
<h6 class="mb-3"><i class="fas fa-file-pdf text-danger me-1"></i>Documento PDF de Términos</h6>
|
||||
|
||||
<div class="mb-3" id="terms-doc-current" style="display:none;">
|
||||
<div class="d-flex align-items-center gap-2 p-2 bg-white rounded border">
|
||||
<i class="fas fa-file-pdf text-danger fa-lg"></i>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="text-truncate small fw-bold" id="terms-doc-name">—</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">Versión: <span id="terms-doc-version">—</span></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="terms-doc-copy-btn" title="Copiar URL">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<a href="#" target="_blank" id="terms-doc-link" class="btn btn-sm btn-outline-primary" title="Abrir PDF">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Versión del nuevo documento <span class="text-muted">(ej. 2026-06)</span></label>
|
||||
<input type="text" class="form-control form-control-sm" id="terms-new-version" placeholder="2026-06">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Subir nuevo PDF</label>
|
||||
<input class="form-control form-control-sm" type="file" id="terms-pdf-file" accept="application/pdf">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input class="form-check-input" type="checkbox" id="terms-force-reaccept">
|
||||
<label class="form-check-label small" for="terms-force-reaccept">
|
||||
<strong>Forzar re-aceptación a todos los usuarios</strong>
|
||||
<span class="text-muted d-block" style="font-size:.75rem;">Al guardar, todos los usuarios deberán aceptar los nuevos términos la próxima vez que escriban.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-danger btn-sm" id="terms-upload-btn">
|
||||
<i class="fas fa-upload me-1"></i>Guardar documento y configuración
|
||||
</button>
|
||||
<div id="terms-upload-status" class="mt-2 small" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── / GESTIÓN DOCUMENTO PDF ────────────────────────────────── -->
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Guardar Configuración
|
||||
</button>
|
||||
@@ -843,7 +887,7 @@ try {
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-6">Versión:</dt>
|
||||
<dd class="col-sm-6">1.0.0</dd>
|
||||
<dd class="col-sm-6"><span class="badge bg-primary">v<?php echo APP_VERSION; ?></span></dd>
|
||||
|
||||
<dt class="col-sm-6">PHP:</dt>
|
||||
<dd class="col-sm-6"><?php echo PHP_VERSION; ?></dd>
|
||||
@@ -851,9 +895,12 @@ try {
|
||||
<dt class="col-sm-6">Base de Datos:</dt>
|
||||
<dd class="col-sm-6"><?php echo $dbStatus; ?></dd>
|
||||
|
||||
<dt class="col-sm-6">IP del Servidor:</dt>
|
||||
<dd class="col-sm-6"><code><?php echo htmlspecialchars($serverIp); ?></code></dd>
|
||||
|
||||
<dt class="col-sm-6">Webhook URL:</dt>
|
||||
<dd class="col-sm-6">
|
||||
<code><?php echo APP_URL; ?>/api/webhook.php</code>
|
||||
<code><?php echo htmlspecialchars(APP_URL); ?>/api/webhook.php</code>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -907,6 +954,73 @@ try {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- T&C Aceptaciones Tab -->
|
||||
<div id="terms" class="tab-content" style="display:none;">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i>Aceptaciones de Términos y Condiciones</h5>
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<!-- Filtros -->
|
||||
<select id="terms-filter-estado" class="form-select form-select-sm" style="width:auto;">
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="pendiente">Pendiente</option>
|
||||
<option value="aceptado">Aceptado</option>
|
||||
<option value="rechazado">Rechazado</option>
|
||||
</select>
|
||||
<input type="date" id="terms-filter-fecha" class="form-control form-control-sm" style="width:auto;">
|
||||
<input type="text" id="terms-filter-phone" class="form-control form-control-sm" placeholder="Teléfono..." style="width:140px;">
|
||||
<button class="btn btn-sm btn-primary" onclick="termsAdmin.cargar()"><i class="fas fa-search"></i></button>
|
||||
<a id="terms-export-btn" href="lab_terminos_export.php" target="_blank" class="btn btn-sm btn-outline-success">
|
||||
<i class="fas fa-file-csv me-1"></i>CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<!-- Stats row -->
|
||||
<div class="d-flex gap-0 border-bottom" id="terms-stats-row">
|
||||
<div class="flex-fill text-center py-3 border-end">
|
||||
<div class="h4 mb-0 text-success fw-bold" id="terms-stat-aceptado">—</div>
|
||||
<div class="text-muted" style="font-size:.8rem;">Aceptaron</div>
|
||||
</div>
|
||||
<div class="flex-fill text-center py-3 border-end">
|
||||
<div class="h4 mb-0 text-danger fw-bold" id="terms-stat-rechazado">—</div>
|
||||
<div class="text-muted" style="font-size:.8rem;">Rechazaron</div>
|
||||
</div>
|
||||
<div class="flex-fill text-center py-3 border-end">
|
||||
<div class="h4 mb-0 text-warning fw-bold" id="terms-stat-pendiente">—</div>
|
||||
<div class="text-muted" style="font-size:.8rem;">Pendientes</div>
|
||||
</div>
|
||||
<div class="flex-fill text-center py-3">
|
||||
<div class="h4 mb-0 fw-bold" id="terms-stat-total">—</div>
|
||||
<div class="text-muted" style="font-size:.8rem;">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Teléfono</th>
|
||||
<th>Nombre</th>
|
||||
<th>Estado</th>
|
||||
<th>Versión T&C</th>
|
||||
<th>Fecha envío</th>
|
||||
<th>Fecha respuesta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="terms-table-body">
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">Haz clic en <i class="fas fa-search"></i> para cargar</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Paginación -->
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top" id="terms-pagination-row" style="display:none!important;">
|
||||
<small class="text-muted" id="terms-pagination-info"></small>
|
||||
<div class="d-flex gap-1" id="terms-pagination-btns"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal - Crear Plantilla -->
|
||||
@@ -987,97 +1101,7 @@ try {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para crear/editar usuario administrador -->
|
||||
<div class="modal fade" id="adminUserModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="adminUserModalLabel">
|
||||
<i class="fas fa-user-shield"></i> <span id="admin-user-modal-title">Nuevo Usuario</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="admin-user-form">
|
||||
<input type="hidden" id="admin-user-id">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nombre de Usuario *</label>
|
||||
<input type="text" class="form-control" id="admin-username" required
|
||||
placeholder="usuario123">
|
||||
<small class="text-muted">Usado para iniciar sesión</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nombre Completo</label>
|
||||
<input type="text" class="form-control" id="admin-fullname"
|
||||
placeholder="Juan Pérez">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="admin-email"
|
||||
placeholder="usuario@ejemplo.com">
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="password-group">
|
||||
<label class="form-label">Contraseña *</label>
|
||||
<input type="password" class="form-control" id="admin-password"
|
||||
placeholder="Mínimo 6 caracteres">
|
||||
<small class="text-muted">Mínimo 6 caracteres</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancelar
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveAdminUser()">
|
||||
<i class="fas fa-save"></i> Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para cambiar contraseña -->
|
||||
<div class="modal fade" id="changePasswordModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-key"></i> Cambiar Contraseña
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="change-password-user-id">
|
||||
<p class="mb-3">Usuario: <strong id="change-password-username"></strong></p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nueva Contraseña *</label>
|
||||
<input type="password" class="form-control" id="new-password"
|
||||
placeholder="Mínimo 6 caracteres" required>
|
||||
<small class="text-muted">Mínimo 6 caracteres</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirmar Contraseña *</label>
|
||||
<input type="password" class="form-control" id="confirm-password"
|
||||
placeholder="Repite la contraseña" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancelar
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveNewPassword()">
|
||||
<i class="fas fa-key"></i> Cambiar Contraseña
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Los modales de usuario se centralizaron en lab_usuarios.php -->
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
@@ -1093,172 +1117,137 @@ try {
|
||||
try {
|
||||
const response = await fetch('api/get_rate_limit_stats.php?action=current');
|
||||
|
||||
// Verificar que la respuesta sea válida
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
console.error('Respuesta no-JSON recibida:', text.substring(0, 200));
|
||||
console.error('Respuesta no-JSON:', text.substring(0, 200));
|
||||
throw new Error('El servidor no devolvió JSON válido');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) throw new Error(result.error || 'Error obteniendo estadísticas');
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Error obteniendo estadísticas');
|
||||
}
|
||||
|
||||
const stats = result.data;
|
||||
updateRateLimitUI(stats);
|
||||
updateRateLimitUI(result.data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error cargando rate limit stats:', error);
|
||||
const statusEl = document.getElementById('rate-limit-status');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML =
|
||||
'<i class="fas fa-exclamation-triangle"></i> Error cargando estadísticas: ' + error.message;
|
||||
statusEl.className = 'alert alert-warning';
|
||||
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Error cargando estadísticas: ' + error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateRateLimitUI(stats) {
|
||||
// Verificar que los elementos existan
|
||||
const statusEl = document.getElementById('rate-limit-status');
|
||||
const callCountBar = document.getElementById('fb-call-count-bar');
|
||||
if (!statusEl) return;
|
||||
|
||||
if (!statusEl || !callCountBar) {
|
||||
console.log('Elementos del rate limit monitor no encontrados en el DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
// Manejar caso donde Redis no está disponible
|
||||
// Redis no disponible
|
||||
if (stats.status === 'unavailable' || stats.error) {
|
||||
statusEl.className = 'alert alert-info';
|
||||
statusEl.innerHTML = '<i class="fas fa-info-circle"></i> Rate Limiting Monitor: Redis no disponible. Las estadísticas de Facebook se mostrarán cuando se realice la próxima llamada a la API.';
|
||||
|
||||
// Mostrar mensaje en las barras de progreso
|
||||
const metricsContainer = callCountBar.closest('.row');
|
||||
if (metricsContainer) {
|
||||
metricsContainer.innerHTML = '<div class="col-12"><div class="alert alert-info text-center"><i class="fas fa-info-circle"></i> Los datos de Facebook API se capturarán automáticamente en la próxima llamada. Redis: ' + (stats.error || 'No disponible') + '</div></div>';
|
||||
}
|
||||
statusEl.innerHTML = '<i class="fas fa-info-circle"></i> Redis no disponible. Los datos de Facebook API se capturarán en la próxima llamada. <small class="d-block text-muted mt-1">' + (stats.error || '') + '</small>';
|
||||
setProgressBarsEmpty();
|
||||
setLocalLimits(stats.local_limits);
|
||||
return;
|
||||
}
|
||||
|
||||
// Actualizar status general
|
||||
const statusEl = document.getElementById('rate-limit-status');
|
||||
let statusClass = 'alert-success';
|
||||
let statusIcon = 'fa-check-circle';
|
||||
let statusText = 'Sistema operando normalmente';
|
||||
|
||||
// Status general
|
||||
let statusClass = 'alert-success', statusIcon = 'fa-check-circle', statusText = 'Sistema operando normalmente';
|
||||
if (stats.status === 'critical') {
|
||||
statusClass = 'alert-danger';
|
||||
statusIcon = 'fa-exclamation-triangle';
|
||||
statusText = '¡ALERTA! Rate limit cerca del límite crítico';
|
||||
statusClass = 'alert-danger'; statusIcon = 'fa-exclamation-triangle'; statusText = '¡ALERTA! Rate limit cerca del límite crítico';
|
||||
} else if (stats.status === 'warning') {
|
||||
statusClass = 'alert-warning';
|
||||
statusIcon = 'fa-exclamation-circle';
|
||||
statusText = 'Advertencia: Acercándose al límite';
|
||||
statusClass = 'alert-warning'; statusIcon = 'fa-exclamation-circle'; statusText = 'Advertencia: Acercándose al límite de Facebook';
|
||||
}
|
||||
statusEl.className = 'alert ' + statusClass;
|
||||
statusEl.innerHTML = '<i class="fas ' + statusIcon + '"></i> ' + statusText +
|
||||
(stats.datetime ? ' <small class="float-end text-muted">Actualizado: ' + stats.datetime + '</small>' : '');
|
||||
|
||||
statusEl.className = `alert ${statusClass}`;
|
||||
statusEl.innerHTML = `<i class="fas ${statusIcon}"></i> ${statusText}`;
|
||||
|
||||
// Actualizar métricas de Facebook
|
||||
// Métricas Facebook
|
||||
if (stats.app_usage) {
|
||||
updateProgressBar('fb-call-count-bar', stats.app_usage.call_count || 0);
|
||||
updateProgressBar('fb-total-time-bar', stats.app_usage.total_time || 0);
|
||||
updateProgressBar('fb-cpu-time-bar', stats.app_usage.total_cputime || 0);
|
||||
} else {
|
||||
// Sin datos de Facebook aún
|
||||
const callCountBar = document.getElementById('fb-call-count-bar');
|
||||
if (callCountBar) {
|
||||
const metricsRow = callCountBar.closest('.row');
|
||||
if (metricsRow) {
|
||||
metricsRow.innerHTML =
|
||||
'<div class="col-12"><div class="alert alert-info text-center"><i class="fas fa-info-circle"></i> Los datos de Facebook API se capturarán automáticamente en la próxima llamada.</div></div>';
|
||||
}
|
||||
}
|
||||
setProgressBarsEmpty();
|
||||
}
|
||||
|
||||
// Actualizar límites locales
|
||||
if (stats.local_limits) {
|
||||
const limitSecondEl = document.getElementById('local-limit-second-max');
|
||||
const limitHourEl = document.getElementById('local-limit-hour-max');
|
||||
const limitDayEl = document.getElementById('local-limit-day-max');
|
||||
// Límites locales
|
||||
setLocalLimits(stats.local_limits);
|
||||
|
||||
if (limitSecondEl) limitSecondEl.textContent = stats.local_limits.per_second;
|
||||
if (limitHourEl) limitHourEl.textContent = stats.local_limits.per_hour;
|
||||
if (limitDayEl) limitDayEl.textContent = stats.local_limits.per_day;
|
||||
}
|
||||
|
||||
// Mostrar alertas si existen
|
||||
// Alertas recientes
|
||||
const alertsSection = document.getElementById('rate-limit-alerts-section');
|
||||
const alertsContainer = document.getElementById('rate-limit-alerts');
|
||||
|
||||
if (stats.alerts && stats.alerts.length > 0 && alertsSection && alertsContainer) {
|
||||
alertsSection.style.display = 'block';
|
||||
alertsContainer.innerHTML = '';
|
||||
|
||||
stats.alerts.slice(0, 5).forEach(alert => {
|
||||
const alertClass = alert.severity === 'critical' ? 'list-group-item-danger' : 'list-group-item-warning';
|
||||
const alertIcon = alert.severity === 'critical' ? 'fa-exclamation-triangle' : 'fa-exclamation-circle';
|
||||
|
||||
const alertEl = document.createElement('div');
|
||||
alertEl.className = `list-group-item ${alertClass}`;
|
||||
alertEl.innerHTML = `
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1"><i class="fas ${alertIcon}"></i> Alerta de ${alert.level}</h6>
|
||||
<small>${alert.datetime}</small>
|
||||
</div>
|
||||
<p class="mb-1">Uso al ${alert.usage}% - Endpoint: ${alert.data.endpoint || 'N/A'}</p>
|
||||
`;
|
||||
alertsContainer.appendChild(alertEl);
|
||||
if (!alert) return;
|
||||
const cls = alert.severity === 'critical' ? 'list-group-item-danger' : 'list-group-item-warning';
|
||||
const icon = alert.severity === 'critical' ? 'fa-exclamation-triangle' : 'fa-exclamation-circle';
|
||||
const el = document.createElement('div');
|
||||
el.className = 'list-group-item ' + cls;
|
||||
el.innerHTML = `<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1"><i class="fas ${icon}"></i> Alerta de ${alert.level || '—'}</h6>
|
||||
<small>${alert.datetime || ''}</small>
|
||||
</div>
|
||||
<p class="mb-1">Uso al ${alert.usage || 0}% — Endpoint: ${(alert.data && alert.data.endpoint) || 'N/A'}</p>`;
|
||||
alertsContainer.appendChild(el);
|
||||
});
|
||||
} else if (alertsSection) {
|
||||
alertsSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function setProgressBarsEmpty() {
|
||||
['fb-call-count-bar','fb-total-time-bar','fb-cpu-time-bar'].forEach(id => {
|
||||
const bar = document.getElementById(id);
|
||||
if (!bar) return;
|
||||
bar.style.width = '0%';
|
||||
bar.textContent = 'Sin datos';
|
||||
bar.className = 'progress-bar bg-secondary';
|
||||
});
|
||||
}
|
||||
|
||||
function setLocalLimits(limits) {
|
||||
if (!limits) return;
|
||||
const map = {
|
||||
'local-limit-second-max': limits.per_second,
|
||||
'local-limit-hour-max': limits.per_hour,
|
||||
'local-limit-day-max': limits.per_day,
|
||||
};
|
||||
Object.entries(map).forEach(([id, val]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && val !== undefined) el.textContent = val.toLocaleString();
|
||||
});
|
||||
// Limpiar guiones de contadores actuales
|
||||
['local-limit-second','local-limit-hour','local-limit-day'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && el.textContent === '-') el.textContent = '—';
|
||||
});
|
||||
}
|
||||
|
||||
function updateProgressBar(id, percentage) {
|
||||
const bar = document.getElementById(id);
|
||||
if (!bar) return;
|
||||
|
||||
percentage = Math.min(100, Math.max(0, percentage));
|
||||
|
||||
percentage = Math.min(100, Math.max(0, parseFloat(percentage) || 0));
|
||||
bar.style.width = percentage + '%';
|
||||
bar.textContent = percentage.toFixed(1) + '%';
|
||||
|
||||
// Cambiar color según el porcentaje
|
||||
bar.className = 'progress-bar';
|
||||
if (percentage >= 90) {
|
||||
bar.classList.add('bg-danger');
|
||||
} else if (percentage >= 75) {
|
||||
bar.classList.add('bg-warning');
|
||||
} else if (percentage >= 50) {
|
||||
bar.classList.add('bg-info');
|
||||
} else {
|
||||
bar.classList.add('bg-success');
|
||||
}
|
||||
if (percentage >= 90) bar.classList.add('bg-danger');
|
||||
else if (percentage >= 75) bar.classList.add('bg-warning');
|
||||
else if (percentage >= 50) bar.classList.add('bg-info');
|
||||
else bar.classList.add('bg-success');
|
||||
}
|
||||
|
||||
// Cargar estadísticas al cargar la página
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Cargar inmediatamente
|
||||
refreshRateLimitStats();
|
||||
// Cargar al iniciar
|
||||
document.addEventListener('DOMContentLoaded', refreshRateLimitStats);
|
||||
|
||||
// Actualizar cada 30 segundos
|
||||
setInterval(refreshRateLimitStats, 30000);
|
||||
});
|
||||
|
||||
// Actualizar también cuando se cambia a la pestaña de dashboard
|
||||
// Recargar al activar el tab dashboard
|
||||
document.querySelectorAll('.nav-link[data-tab="dashboard"]').forEach(link => {
|
||||
link.addEventListener('click', function() {
|
||||
setTimeout(refreshRateLimitStats, 500);
|
||||
});
|
||||
link.addEventListener('click', () => setTimeout(refreshRateLimitStats, 500));
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
@@ -2154,6 +2143,117 @@ try {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ── T&C Aceptaciones JS ───────────────────────────────────────────── -->
|
||||
<script>
|
||||
const termsAdmin = (() => {
|
||||
let _page = 1;
|
||||
const perPage = 50;
|
||||
|
||||
function estadoBadge(e) {
|
||||
const map = {
|
||||
aceptado: '<span class="badge bg-success">Aceptado</span>',
|
||||
rechazado: '<span class="badge bg-danger">Rechazado</span>',
|
||||
pendiente: '<span class="badge bg-warning text-dark">Pendiente</span>',
|
||||
};
|
||||
return map[e] || `<span class="badge bg-secondary">${e}</span>`;
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '—';
|
||||
const dt = new Date(d.replace(' ', 'T'));
|
||||
return dt.toLocaleString('es-CO', { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
async function cargar(page) {
|
||||
_page = page || 1;
|
||||
const estado = document.getElementById('terms-filter-estado')?.value || '';
|
||||
const fecha = document.getElementById('terms-filter-fecha')?.value || '';
|
||||
const phone = document.getElementById('terms-filter-phone')?.value || '';
|
||||
|
||||
const params = new URLSearchParams({ page: _page, per_page: perPage });
|
||||
if (estado) params.set('estado', estado);
|
||||
if (fecha) params.set('fecha', fecha);
|
||||
if (phone) params.set('phone', phone);
|
||||
|
||||
// Actualizar link de exportación
|
||||
const expBtn = document.getElementById('terms-export-btn');
|
||||
if (expBtn) {
|
||||
const ep = new URLSearchParams();
|
||||
if (estado) ep.set('estado', estado);
|
||||
if (fecha) ep.set('fecha', fecha);
|
||||
if (phone) ep.set('phone', phone);
|
||||
expBtn.href = 'lab_terminos_export.php?' + ep.toString();
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('terms-table-body');
|
||||
if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="text-center py-3"><div class="spinner-border spinner-border-sm text-primary"></div></td></tr>';
|
||||
|
||||
try {
|
||||
const res = await fetch('api/get_terms_acceptances.php?' + params.toString());
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success) throw new Error(json.error || 'Error al cargar');
|
||||
|
||||
const rows = json.data || [];
|
||||
const stats = json.stats || {};
|
||||
const total = json.total || 0;
|
||||
const pages = Math.ceil(total / perPage);
|
||||
|
||||
// Stats
|
||||
document.getElementById('terms-stat-aceptado').textContent = stats.aceptado ?? 0;
|
||||
document.getElementById('terms-stat-rechazado').textContent = stats.rechazado ?? 0;
|
||||
document.getElementById('terms-stat-pendiente').textContent = stats.pendiente ?? 0;
|
||||
document.getElementById('terms-stat-total').textContent = total;
|
||||
|
||||
// Tabla
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Sin registros</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr>
|
||||
<td class="text-muted" style="font-size:.8rem;">${r.id}</td>
|
||||
<td><code style="font-size:.8rem;">${r.phone_number}</code></td>
|
||||
<td>${r.user_name || '<span class="text-muted">—</span>'}</td>
|
||||
<td>${estadoBadge(r.estado)}</td>
|
||||
<td><span class="badge bg-secondary">${r.terms_version || '—'}</span></td>
|
||||
<td style="font-size:.8rem;">${formatDate(r.fecha_envio)}</td>
|
||||
<td style="font-size:.8rem;">${formatDate(r.fecha_respuesta)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// Paginación
|
||||
const pInfo = document.getElementById('terms-pagination-info');
|
||||
const pBtns = document.getElementById('terms-pagination-btns');
|
||||
const pRow = document.getElementById('terms-pagination-row');
|
||||
if (pRow) pRow.style.display = pages > 1 ? 'flex' : 'none';
|
||||
if (pInfo) pInfo.textContent = `Página ${_page} de ${pages} (${total} registros)`;
|
||||
if (pBtns) {
|
||||
pBtns.innerHTML = '';
|
||||
for (let p = 1; p <= Math.min(pages, 10); p++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm ' + (p === _page ? 'btn-primary' : 'btn-outline-secondary');
|
||||
btn.textContent = p;
|
||||
btn.onclick = () => cargar(p);
|
||||
pBtns.appendChild(btn);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('termsAdmin.cargar error', e);
|
||||
if (tbody) tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar cuando se activa el tab
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const link = document.querySelector('.nav-link[data-tab="terms"]');
|
||||
if (link) link.addEventListener('click', () => setTimeout(() => cargar(1), 50));
|
||||
});
|
||||
|
||||
return { cargar };
|
||||
})();
|
||||
</script>
|
||||
<!-- ── / T&C Aceptaciones JS ─────────────────────────────────────────── -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
/**
|
||||
* lab_configuracion.php — Configuración global del laboratorio
|
||||
* Solo admin.
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: login.php'); exit; }
|
||||
if (isEnfermero()) { header('Location: lab_dashboard.php'); exit; }
|
||||
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? 'Admin';
|
||||
|
||||
// Cargar config actual
|
||||
$db = Database::getInstance();
|
||||
$rows= $db->fetchAll('SELECT clave, valor FROM lab_config ORDER BY clave');
|
||||
$cfg = [];
|
||||
foreach ($rows as $r) { $cfg[$r['clave']] = $r['valor']; }
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Configuración Lab</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
<style>
|
||||
.preview-header {
|
||||
border-radius: 10px; color: #fff;
|
||||
padding: 20px 24px; margin-top: 16px;
|
||||
}
|
||||
.preview-header h4 { margin: 0 0 2px; font-size: 15px; font-weight: 700; }
|
||||
.preview-header .sub { font-size: 12px; opacity: .8; }
|
||||
.preview-logo { max-height: 60px; max-width: 100px;
|
||||
border-radius: 6px; background: rgba(255,255,255,.2); padding: 3px; }
|
||||
.section-card { border-radius: 10px; border: 1px solid #dee2e6;
|
||||
background: #fff; padding: 20px 24px; margin-bottom: 20px; }
|
||||
.section-card h6 { border-bottom: 2px solid #0d6efd; padding-bottom: 8px;
|
||||
margin-bottom: 16px; font-weight: 700; color: #1565c0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_ordenes.php" class="nav-link"><i class="fas fa-file-medical"></i> Órdenes</a></li>
|
||||
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<li><a href="lab_enfermeras.php" class="nav-link"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
|
||||
<li><a href="lab_formularios.php" class="nav-link"><i class="fas fa-wpforms"></i> Formularios</a></li>
|
||||
<li><a href="lab_reportes.php" class="nav-link"><i class="fas fa-chart-bar"></i> Reportes</a></li>
|
||||
<li><a href="lab_configuracion.php" class="nav-link active"><i class="fas fa-sliders-h"></i> Configuración</a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
|
||||
<li><a href="logout.php" class="nav-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="content-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h1><i class="fas fa-sliders-h text-primary"></i> Configuración del Laboratorio</h1>
|
||||
<small class="text-muted">Personaliza el encabezado, logo y datos que aparecen en los documentos PDF</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid py-3" style="max-width:860px">
|
||||
|
||||
<div id="toast-ok" class="alert alert-success" style="display:none;position:fixed;top:70px;right:20px;z-index:9999;min-width:260px">
|
||||
<i class="fas fa-check-circle me-2"></i>Configuración guardada correctamente
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-7">
|
||||
|
||||
<!-- ── Datos de la empresa ─────────────────────────── -->
|
||||
<div class="section-card">
|
||||
<h6><i class="fas fa-building me-1"></i>Datos de la empresa / laboratorio</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Nombre del laboratorio <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="c-empresa-nombre"
|
||||
value="<?= htmlspecialchars($cfg['empresa_nombre'] ?? '') ?>"
|
||||
placeholder="Ej: Laboratorio Clínico Ximena">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Subtítulo</label>
|
||||
<input type="text" class="form-control" id="c-empresa-subtitulo"
|
||||
value="<?= htmlspecialchars($cfg['empresa_subtitulo'] ?? '') ?>"
|
||||
placeholder="Ej: Análisis Clínicos Especializados">
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold small">Teléfono</label>
|
||||
<input type="text" class="form-control" id="c-empresa-telefono"
|
||||
value="<?= htmlspecialchars($cfg['empresa_telefono'] ?? '') ?>"
|
||||
placeholder="+57 300 000 0000">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold small">Ciudad</label>
|
||||
<input type="text" class="form-control" id="c-empresa-ciudad"
|
||||
value="<?= htmlspecialchars($cfg['empresa_ciudad'] ?? '') ?>"
|
||||
placeholder="Bogotá, Colombia">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold small">Email</label>
|
||||
<input type="email" class="form-control" id="c-empresa-email"
|
||||
value="<?= htmlspecialchars($cfg['empresa_email'] ?? '') ?>"
|
||||
placeholder="info@laboratorio.com">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold small">Dirección</label>
|
||||
<input type="text" class="form-control" id="c-empresa-direccion"
|
||||
value="<?= htmlspecialchars($cfg['empresa_direccion'] ?? '') ?>"
|
||||
placeholder="Calle 10 #20-30">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Diseño del documento ────────────────────────── -->
|
||||
<div class="section-card">
|
||||
<h6><i class="fas fa-paint-brush me-1"></i>Diseño del encabezado del documento</h6>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold small">Color del encabezado</label>
|
||||
<input type="color" class="form-control form-control-color w-100"
|
||||
id="c-doc-color"
|
||||
value="<?= htmlspecialchars($cfg['doc_color'] ?? '#1565c0') ?>"
|
||||
oninput="actualizarPreview()">
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold small">Logo del laboratorio</label>
|
||||
<input type="file" class="form-control form-control-sm" id="c-logo-file"
|
||||
accept="image/png,image/jpeg,image/gif,image/svg+xml"
|
||||
onchange="cargarLogo(this)">
|
||||
<div class="form-text">PNG, JPG o SVG recomendado. Máx. 200px de alto.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($cfg['doc_logo_base64'])): ?>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Logo actual:</label>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<img id="c-logo-preview" src="<?= htmlspecialchars($cfg['doc_logo_base64']) ?>"
|
||||
alt="Logo" style="max-height:60px;border:1px solid #dee2e6;border-radius:6px;padding:4px">
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="quitarLogo()">
|
||||
<i class="fas fa-trash me-1"></i>Quitar logo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="mb-3" id="logo-preview-wrap" style="display:none">
|
||||
<label class="form-label small fw-semibold">Vista previa del logo:</label>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<img id="c-logo-preview" src="" alt="Logo"
|
||||
style="max-height:60px;border:1px solid #dee2e6;border-radius:6px;padding:4px">
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="quitarLogo()">
|
||||
<i class="fas fa-trash me-1"></i>Quitar logo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<input type="hidden" id="c-logo-base64" value="<?= htmlspecialchars($cfg['doc_logo_base64'] ?? '') ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Pie de página del documento</label>
|
||||
<input type="text" class="form-control" id="c-doc-pie"
|
||||
value="<?= htmlspecialchars($cfg['doc_pie_pagina'] ?? '') ?>"
|
||||
placeholder="Ej: Documento de uso confidencial. Prohibida su reproducción.">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-lg w-100" onclick="guardar()">
|
||||
<i class="fas fa-save me-2"></i>Guardar configuración
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Vista previa ──────────────────────────────── -->
|
||||
<div class="col-lg-5">
|
||||
<div class="section-card" style="position:sticky;top:80px">
|
||||
<h6><i class="fas fa-eye me-1"></i>Vista previa del encabezado</h6>
|
||||
|
||||
<div id="preview-box" class="preview-header"
|
||||
style="background: <?= htmlspecialchars($cfg['doc_color'] ?? '#1565c0') ?>">
|
||||
<div style="display:flex;align-items:flex-start;gap:12px">
|
||||
<div id="preview-logo-wrap" style="display:<?= !empty($cfg['doc_logo_base64']) ? '' : 'none' ?>">
|
||||
<img id="preview-logo" src="<?= htmlspecialchars($cfg['doc_logo_base64'] ?? '') ?>"
|
||||
class="preview-logo" alt="Logo">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<h4 id="preview-nombre"><?= htmlspecialchars($cfg['empresa_nombre'] ?? 'Nombre del laboratorio') ?></h4>
|
||||
<div class="sub" id="preview-subtitulo"><?= htmlspecialchars($cfg['empresa_subtitulo'] ?? 'Subtítulo') ?></div>
|
||||
<div class="sub" style="margin-top:2px" id="preview-contacto">
|
||||
<?php
|
||||
$c = array_filter([
|
||||
$cfg['empresa_direccion'] ?? '',
|
||||
$cfg['empresa_ciudad'] ?? '',
|
||||
$cfg['empresa_telefono'] ?? '',
|
||||
]);
|
||||
echo htmlspecialchars(implode(' · ', $c));
|
||||
?>
|
||||
</div>
|
||||
<h5 style="margin:8px 0 0;border-top:1px solid rgba(255,255,255,.3);padding-top:8px;font-size:14px">
|
||||
Nombre del formulario
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 p-3 bg-light rounded small text-muted">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Este encabezado aparecerá en todos los documentos PDF generados.
|
||||
Cada formulario puede tener su propio encabezado desde el editor de formularios.
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="ver_formulario_enviado.php?id=<?php
|
||||
$uno = $db->fetch('SELECT id FROM lab_form_envios WHERE estado IN (\'firmado\',\'completado\') ORDER BY id DESC LIMIT 1');
|
||||
echo $uno ? (int)$uno['id'] : '0';
|
||||
?>" target="_blank" class="btn btn-outline-primary btn-sm w-100">
|
||||
<i class="fas fa-external-link-alt me-1"></i>Ver ejemplo de documento
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
function actualizarPreview() {
|
||||
const color = $('c-doc-color').value;
|
||||
const nombre = $('c-empresa-nombre').value || 'Nombre del laboratorio';
|
||||
const sub = $('c-empresa-subtitulo').value || 'Subtítulo';
|
||||
const tel = $('c-empresa-telefono').value;
|
||||
const dir = $('c-empresa-direccion').value;
|
||||
const ciudad = $('c-empresa-ciudad').value;
|
||||
|
||||
$('preview-box').style.background = color;
|
||||
$('preview-nombre').textContent = nombre;
|
||||
$('preview-subtitulo').textContent = sub;
|
||||
const ctc = [dir, ciudad, tel].filter(Boolean).join(' · ');
|
||||
$('preview-contacto').textContent = ctc;
|
||||
}
|
||||
|
||||
// Actualizar preview al escribir
|
||||
['c-empresa-nombre','c-empresa-subtitulo','c-empresa-telefono',
|
||||
'c-empresa-direccion','c-empresa-ciudad'].forEach(id => {
|
||||
$(id)?.addEventListener('input', actualizarPreview);
|
||||
});
|
||||
|
||||
function cargarLogo(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const b64 = e.target.result;
|
||||
$('c-logo-base64').value = b64;
|
||||
$('c-logo-preview').src = b64;
|
||||
$('preview-logo').src = b64;
|
||||
const wrap = $('logo-preview-wrap') || $('c-logo-preview').parentElement;
|
||||
if (wrap) wrap.style.display = '';
|
||||
$('preview-logo-wrap').style.display = '';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function quitarLogo() {
|
||||
$('c-logo-base64').value = '';
|
||||
$('c-logo-preview').src = '';
|
||||
$('preview-logo').src = '';
|
||||
$('preview-logo-wrap').style.display = 'none';
|
||||
const wrap = $('logo-preview-wrap');
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
$('c-logo-file').value = '';
|
||||
}
|
||||
|
||||
async function guardar() {
|
||||
const payload = {
|
||||
empresa_nombre: $('c-empresa-nombre').value.trim(),
|
||||
empresa_subtitulo: $('c-empresa-subtitulo').value.trim(),
|
||||
empresa_telefono: $('c-empresa-telefono').value.trim(),
|
||||
empresa_email: $('c-empresa-email').value.trim(),
|
||||
empresa_direccion: $('c-empresa-direccion').value.trim(),
|
||||
empresa_ciudad: $('c-empresa-ciudad').value.trim(),
|
||||
doc_color: $('c-doc-color').value,
|
||||
doc_logo_base64: $('c-logo-base64').value,
|
||||
doc_pie_pagina: $('c-doc-pie').value.trim(),
|
||||
};
|
||||
|
||||
if (!payload.empresa_nombre) {
|
||||
$('c-empresa-nombre').focus();
|
||||
$('c-empresa-nombre').classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.querySelector('button[onclick="guardar()"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Guardando…';
|
||||
|
||||
try {
|
||||
const r = await fetch('api/lab/save_config.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
const t = $('toast-ok');
|
||||
t.style.display = 'block';
|
||||
setTimeout(() => { t.style.display = 'none'; }, 3500);
|
||||
} else {
|
||||
alert(d.error || 'Error al guardar');
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-save me-2"></i>Guardar configuración';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
/**
|
||||
* Dashboard del Módulo Administrativo de Laboratorio
|
||||
* Punto de entrada principal del módulo
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||||
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Módulo Lab — Dashboard</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--lab-primary: #0d6efd;
|
||||
--lab-success: #198754;
|
||||
--lab-warning: #fd7e14;
|
||||
--lab-danger: #dc3545;
|
||||
--lab-info: #0dcaf0;
|
||||
}
|
||||
.lab-card {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,.07);
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
}
|
||||
.lab-card:hover { transform: translateY(-2px); box-shadow: 0 4px 20px rgba(0,0,0,.12); }
|
||||
.stat-icon { width: 48px; height: 48px; border-radius: 12px; display:flex; align-items:center; justify-content:center; font-size: 1.4rem; }
|
||||
.badge-estado { font-size: .72rem; padding: .3em .6em; border-radius: 6px; }
|
||||
.actividad-item { padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.actividad-item:last-child { border-bottom: none; }
|
||||
.pendiente-urgente { background: #fff8f0; border-left: 4px solid var(--lab-warning); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar heredado del sistema -->
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h4><i class="fas fa-flask"></i> Módulo Lab</h4>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="lab_dashboard.php" class="nav-link active"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_ordenes.php" class="nav-link"><i class="fas fa-file-medical"></i> Órdenes Médicas</a></li>
|
||||
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_domicilios.php"class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<li><a href="lab_enfermeras.php"class="nav-link"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
|
||||
<li><a href="lab_formularios.php" class="nav-link"><i class="fas fa-wpforms"></i> Formularios</a></li>
|
||||
<li><a href="lab_reportes.php" class="nav-link"><i class="fas fa-chart-bar"></i> Reportes</a></li>
|
||||
<li><a href="lab_configuracion.php" class="nav-link"><i class="fas fa-sliders-h"></i> Configuración</a></li>
|
||||
<li><a href="lab_usuarios.php" class="nav-link"><i class="fas fa-users-cog"></i> Usuarios</a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
|
||||
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="content-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h1><i class="fas fa-flask text-primary"></i> Módulo Administrativo — Laboratorio</h1>
|
||||
<small class="text-muted"><i class="fas fa-user"></i> <?= htmlspecialchars($adminNombre) ?> — <?= date('l, d \d\e F \d\e Y') ?></small>
|
||||
</div>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="cargarStats()">
|
||||
<i class="fas fa-sync-alt"></i> Actualizar
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<!-- ── Tarjetas resumen ─────────────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4" id="stats-cards">
|
||||
<!-- Se renderizan dinámicamente -->
|
||||
<div class="col-12 text-center py-4 text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i> Cargando estadísticas...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- ── Órdenes pendientes urgentes ─────────────────────────── -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lab-card card h-100">
|
||||
<div class="card-header bg-white border-0 pb-0">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<i class="fas fa-clock text-warning me-2"></i>Órdenes sin revisar (más antiguas)
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-0" id="pendientes-list">
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Domicilios sin asignar hoy ─────────────────────────── -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lab-card card h-100">
|
||||
<div class="card-header bg-white border-0 pb-0">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<i class="fas fa-house-medical text-danger me-2"></i>Domicilios sin enfermera — hoy
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-0" id="sinasignar-list">
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Actividad reciente ──────────────────────────────────── -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lab-card card h-100">
|
||||
<div class="card-header bg-white border-0 pb-0">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<i class="fas fa-history text-info me-2"></i>Actividad reciente
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body" id="actividad-list">
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Carga de enfermeras hoy ─────────────────────────────── -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lab-card card h-100">
|
||||
<div class="card-header bg-white border-0 pb-0">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<i class="fas fa-user-nurse text-success me-2"></i>Carga de enfermeras — hoy
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body" id="carga-list">
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /row -->
|
||||
</div><!-- /container -->
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const ESTADOS_COLOR = {
|
||||
pendiente: 'warning',
|
||||
en_revision: 'info',
|
||||
autorizada: 'success',
|
||||
rechazada: 'danger',
|
||||
en_domicilio: 'primary',
|
||||
completada: 'secondary',
|
||||
};
|
||||
|
||||
async function cargarStats() {
|
||||
try {
|
||||
const r = await fetch('api/lab/get_stats.php');
|
||||
const d = await r.json();
|
||||
if (!d.success) return;
|
||||
|
||||
renderCards(d);
|
||||
renderPendientes(d.ordenes.pendientes_viejos);
|
||||
renderSinAsignar(d.domicilios.lista_sin_asignar);
|
||||
renderActividad(d.actividad_reciente);
|
||||
renderCarga(d.enfermeras.carga_hoy);
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCards(d) {
|
||||
const cards = [
|
||||
{ icon: 'fa-file-medical', color: 'warning', label: 'Órdenes pendientes', val: d.ordenes.pendientes_hoy, href: 'lab_ordenes.php?estado=pendiente' },
|
||||
{ icon: 'fa-magnifying-glass',color: 'info', label: 'En revisión', val: d.ordenes.en_revision_hoy, href: 'lab_ordenes.php?estado=en_revision'},
|
||||
{ icon: 'fa-house-medical', color: 'danger', label: 'Domicilios sin asignar',val: d.domicilios.sin_asignar, href: 'lab_domicilios.php' },
|
||||
{ icon: 'fa-calendar-check', color: 'success', label: 'Domicilios hoy', val: d.domicilios.total_hoy, href: 'lab_domicilios.php' },
|
||||
{ icon: 'fa-users', color: 'primary', label: 'Pacientes', val: d.generales.total_pacientes, href: 'lab_pacientes.php' },
|
||||
{ icon: 'fa-user-nurse', color: 'success', label: 'Enfermeras activas', val: d.enfermeras.activas, href: 'lab_enfermeras.php' },
|
||||
];
|
||||
|
||||
document.getElementById('stats-cards').innerHTML = cards.map(c => `
|
||||
<div class="col-sm-6 col-xl-4">
|
||||
<a href="${c.href}" class="text-decoration-none">
|
||||
<div class="lab-card card">
|
||||
<div class="card-body d-flex align-items-center gap-3 py-3">
|
||||
<div class="stat-icon bg-${c.color} bg-opacity-10 text-${c.color}">
|
||||
<i class="fas ${c.icon}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-4 fw-bold lh-1">${c.val}</div>
|
||||
<div class="text-muted small">${c.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderPendientes(lista) {
|
||||
const el = document.getElementById('pendientes-list');
|
||||
if (!lista.length) {
|
||||
el.innerHTML = '<div class="text-center py-4 text-success"><i class="fas fa-check-circle me-2"></i>Sin órdenes pendientes</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light"><tr>
|
||||
<th>Paciente</th><th>Espera</th><th></th>
|
||||
</tr></thead><tbody>` +
|
||||
lista.map(o => `<tr class="pendiente-urgente">
|
||||
<td class="py-2">
|
||||
<div class="fw-semibold">${esc(o.nombre_completo)}</div>
|
||||
<small class="text-muted">#${o.id}</small>
|
||||
</td>
|
||||
<td class="py-2 text-warning fw-semibold">${o.horas_espera}h</td>
|
||||
<td class="py-2">
|
||||
<a href="lab_ordenes.php?id=${o.id}" class="btn btn-xs btn-outline-primary py-0 px-2">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>`).join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function renderSinAsignar(lista) {
|
||||
const el = document.getElementById('sinasignar-list');
|
||||
if (!lista.length) {
|
||||
el.innerHTML = '<div class="text-center py-4 text-success"><i class="fas fa-check-circle me-2"></i>Todos asignados</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light"><tr><th>Paciente</th><th>Hora</th><th></th></tr></thead><tbody>` +
|
||||
lista.map(d => `<tr>
|
||||
<td class="py-2">
|
||||
<div class="fw-semibold">${esc(d.paciente_nombre)}</div>
|
||||
<small class="text-muted">${esc(d.barrio || d.direccion)}</small>
|
||||
</td>
|
||||
<td class="py-2">${d.hora_programada || '—'}</td>
|
||||
<td class="py-2">
|
||||
<a href="lab_domicilios.php?id=${d.id}" class="btn btn-xs btn-outline-danger py-0 px-2">
|
||||
Asignar
|
||||
</a>
|
||||
</td>
|
||||
</tr>`).join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function renderActividad(lista) {
|
||||
const el = document.getElementById('actividad-list');
|
||||
if (!lista.length) {
|
||||
el.innerHTML = '<p class="text-muted text-center py-3">Sin actividad reciente</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = lista.map(a => `
|
||||
<div class="actividad-item d-flex gap-2">
|
||||
<div><i class="fas fa-circle-dot text-primary opacity-50 mt-1"></i></div>
|
||||
<div class="flex-grow-1">
|
||||
<span class="fw-semibold">${esc(a.admin_nombre || a.admin_username || 'Sistema')}</span>
|
||||
—
|
||||
<span class="text-muted">${esc(a.modulo)} / ${esc(a.accion)}</span>
|
||||
<br><small class="text-muted">${formatFecha(a.created_at)}</small>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderCarga(lista) {
|
||||
const el = document.getElementById('carga-list');
|
||||
if (!lista.length) {
|
||||
el.innerHTML = '<p class="text-muted text-center py-3">Sin asignaciones hoy</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = lista.map(e => `
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div>
|
||||
<i class="fas fa-user-nurse text-success me-2"></i>
|
||||
<span class="fw-semibold">${esc(e.nombre_completo)}</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-primary">${e.total_asignaciones} dom.</span>
|
||||
<span class="badge bg-success">${e.completados || 0} ok</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||
const formatFecha = s => s ? new Date(s).toLocaleString('es-CO', {day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}) : '';
|
||||
|
||||
// Cargar al iniciar y cada 60 s
|
||||
cargarStats();
|
||||
setInterval(cargarStats, 60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,516 @@
|
||||
<?php
|
||||
/**
|
||||
* Gestión de Domicilios — Módulo Administrativo de Laboratorio
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
$ordenIdParam = (int)($_GET['orden_id'] ?? 0);
|
||||
$pacIdParam = (int)($_GET['paciente_id']?? 0);
|
||||
$domIdParam = (int)($_GET['id'] ?? 0);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Domicilios — Módulo Lab</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
<style>
|
||||
.dom-row { cursor:pointer; }
|
||||
.estado-badge { font-size:.72rem; }
|
||||
.agenda-card { border-left: 4px solid #dee2e6; padding: 8px 12px; margin-bottom: 8px; border-radius: 0 8px 8px 0; }
|
||||
.agenda-card.programado { border-left-color: #6c757d; }
|
||||
.agenda-card.en_camino { border-left-color: #0d6efd; }
|
||||
.agenda-card.completado { border-left-color: #198754; }
|
||||
.agenda-card.cancelado { border-left-color: #dc3545; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_ordenes.php" class="nav-link"><i class="fas fa-file-medical"></i> Órdenes</a></li>
|
||||
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_domicilios.php" class="nav-link active"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<li><a href="lab_enfermeras.php" class="nav-link"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
|
||||
<li><a href="lab_formularios.php" class="nav-link"><i class="fas fa-wpforms"></i> Formularios</a></li>
|
||||
<li><a href="lab_reportes.php" class="nav-link"><i class="fas fa-chart-bar"></i> Reportes</a></li>
|
||||
<li><a href="lab_configuracion.php" class="nav-link"><i class="fas fa-sliders-h"></i> Configuración</a></li>
|
||||
<li><a href="lab_usuarios.php" class="nav-link"><i class="fas fa-users-cog"></i> Usuarios</a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
|
||||
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="content-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h1><i class="fas fa-house-medical text-primary"></i> Domicilios</h1>
|
||||
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="abrirFormulario()">
|
||||
<i class="fas fa-plus me-1"></i> Nuevo Domicilio
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<!-- Filtros -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-2">
|
||||
<input type="date" id="filtro-fecha" class="form-control form-control-sm"
|
||||
value="<?= date('Y-m-d') ?>" onchange="cargarLista()">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select id="filtro-estado" class="form-select form-select-sm" onchange="cargarLista()">
|
||||
<option value="">Todos los estados</option>
|
||||
<option>programado</option><option>confirmado</option><option>en_camino</option>
|
||||
<option>en_domicilio</option><option>completado</option><option>cancelado</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select id="filtro-enfermera" class="form-select form-select-sm" onchange="cargarLista()">
|
||||
<option value="">Todas las enfermeras</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-sm btn-secondary w-100" onclick="verHoy()">
|
||||
<i class="fas fa-calendar-day me-1"></i> Hoy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resumen del día -->
|
||||
<div class="row g-2 mb-3" id="resumen-hoy"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Tabla -->
|
||||
<div class="col-lg-5">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Hora</th><th>Paciente</th><th>Enfermera</th><th>Estado</th></tr>
|
||||
</thead>
|
||||
<tbody id="tabla-body">
|
||||
<tr><td colspan="4" class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between px-3 py-2 border-top" id="paginacion"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detalle -->
|
||||
<div class="col-lg-7">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0 d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-semibold">Detalle <span id="detail-id" class="text-muted"></span></h6>
|
||||
<button class="btn-close" onclick="cerrarDetalle()"></button>
|
||||
</div>
|
||||
<div class="card-body" id="detail-body">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-arrow-left me-2"></i>Selecciona un domicilio
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal Formulario -->
|
||||
<div class="modal fade" id="modalDomicilio" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-house-medical me-2"></i><span id="modal-titulo">Nuevo Domicilio</span></h5>
|
||||
<button class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="form-domicilio">
|
||||
<input type="hidden" name="id" id="dom-id">
|
||||
<input type="hidden" name="orden_id" id="dom-orden-id" value="<?= $ordenIdParam ?>">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Paciente <span class="text-danger">*</span></label>
|
||||
<input type="hidden" name="paciente_id" id="dom-paciente-id" value="<?= $pacIdParam ?>">
|
||||
<input type="text" class="form-control" id="dom-paciente-nombre" placeholder="Buscar paciente…" autocomplete="off">
|
||||
<div class="list-group mt-1 shadow-sm" id="pac-suggest" style="position:absolute;z-index:1000;max-height:200px;overflow-y:auto;display:none"></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Tipo de servicio</label>
|
||||
<input type="text" class="form-control" name="tipo_servicio" id="dom-tipo">
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Dirección <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="direccion" id="dom-dir" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Ciudad</label>
|
||||
<input type="text" class="form-control" name="ciudad" id="dom-ciudad">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Barrio</label>
|
||||
<input type="text" class="form-control" name="barrio" id="dom-barrio">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Indicaciones de la dirección</label>
|
||||
<input type="text" class="form-control" name="indicaciones_dir" id="dom-indic" placeholder="Ej: apto 302, tocar campanilla">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Fecha programada <span class="text-danger">*</span></label>
|
||||
<input type="date" class="form-control" name="fecha_programada" id="dom-fecha" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Hora</label>
|
||||
<input type="time" class="form-control" name="hora_programada" id="dom-hora">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notas</label>
|
||||
<textarea class="form-control" name="notas_admin" id="dom-notas" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-primary" onclick="guardarDomicilio()"><i class="fas fa-save me-1"></i> Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const modal = new bootstrap.Modal('#modalDomicilio');
|
||||
const COLOR_DOM = { programado:'secondary', confirmado:'info', en_camino:'primary', en_domicilio:'warning', completado:'success', cancelado:'danger', reprogramado:'dark' };
|
||||
let domSeleccionado = <?= $domIdParam ?: 'null' ?>;
|
||||
let paginaActual = 1;
|
||||
let _listaToken = 0; // anti race-condition
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
(async function init() {
|
||||
await cargarEnfermeras();
|
||||
await cargarLista();
|
||||
if (domSeleccionado) verDomicilio(domSeleccionado);
|
||||
if (<?= $ordenIdParam ?> && <?= $pacIdParam ?>) abrirFormulario();
|
||||
})();
|
||||
|
||||
async function cargarEnfermeras() {
|
||||
const r = await fetch('api/lab/get_enfermeras.php');
|
||||
const d = await r.json();
|
||||
const sel = document.getElementById('filtro-enfermera');
|
||||
(d.data||[]).forEach(e => {
|
||||
sel.insertAdjacentHTML('beforeend', `<option value="${e.id}">${esc(e.nombre_completo)}</option>`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lista ──────────────────────────────────────────────────────────────────
|
||||
async function cargarLista(pag = 1) {
|
||||
paginaActual = pag;
|
||||
const miToken = ++_listaToken; // capturar token antes del await
|
||||
const params = new URLSearchParams({
|
||||
fecha: document.getElementById('filtro-fecha').value || '',
|
||||
estado: document.getElementById('filtro-estado').value,
|
||||
enfermera_id: document.getElementById('filtro-enfermera').value,
|
||||
page: pag, limit: 25,
|
||||
});
|
||||
|
||||
const r = await fetch(`api/lab/get_domicilios.php?${params}`);
|
||||
const d = await r.json();
|
||||
|
||||
if (miToken !== _listaToken) return; // respuesta desactualizada, ignorar
|
||||
|
||||
renderResumen(d.estadisticas_hoy, d.domicilios?.sin_asignar ?? d.sin_asignar_hoy);
|
||||
|
||||
const tbody = document.getElementById('tabla-body');
|
||||
if (!d.data?.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Sin domicilios</td></tr>';
|
||||
document.getElementById('paginacion').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = d.data.map(dom => `
|
||||
<tr class="dom-row ${domSeleccionado===dom.id?'table-active':''}" id="row-${dom.id}" onclick="verDomicilio(${dom.id})">
|
||||
<td>${dom.hora_programada||'—'}</td>
|
||||
<td>
|
||||
<div class="fw-semibold">${esc(dom.paciente_nombre)}</div>
|
||||
<small class="text-muted">${esc(dom.barrio||'')}</small>
|
||||
</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>'}</td>
|
||||
<td><span class="badge bg-${COLOR_DOM[dom.estado]||'secondary'} estado-badge">${esc(dom.estado)}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const el = document.getElementById('paginacion');
|
||||
if (d.paginas > 1) {
|
||||
el.innerHTML = `
|
||||
<small class="text-muted">${d.total} domicilio(s)</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
${pag>1?`<button class="btn btn-outline-secondary" onclick="cargarLista(${pag-1})"><i class="fas fa-chevron-left"></i></button>`:''}
|
||||
<button class="btn btn-secondary disabled">${pag}/${d.paginas}</button>
|
||||
${pag<d.paginas?`<button class="btn btn-outline-secondary" onclick="cargarLista(${pag+1})"><i class="fas fa-chevron-right"></i></button>`:''}
|
||||
</div>`;
|
||||
} else {
|
||||
el.innerHTML = `<small class="text-muted">${d.total} domicilio(s)</small>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResumen(stats, sinAsignar) {
|
||||
if (!stats) return;
|
||||
const items = [
|
||||
{ label:'Programados', val: stats.programado||0, color:'secondary' },
|
||||
{ label:'En camino', val: stats.en_camino||0, color:'primary' },
|
||||
{ label:'Completados', val: stats.completado||0, color:'success' },
|
||||
{ label:'Sin enfermera',val: sinAsignar||0, color:'danger' },
|
||||
];
|
||||
document.getElementById('resumen-hoy').innerHTML = items.map(i =>
|
||||
`<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-2">
|
||||
<div class="fs-4 fw-bold text-${i.color}">${i.val}</div>
|
||||
<div class="text-muted small">${i.label}</div>
|
||||
</div>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ── Detalle ────────────────────────────────────────────────────────────────
|
||||
async function verDomicilio(id) {
|
||||
domSeleccionado = id;
|
||||
document.querySelectorAll('.dom-row').forEach(r => r.classList.remove('table-active'));
|
||||
const rowEl = document.getElementById(`row-${id}`);
|
||||
if (rowEl) rowEl.classList.add('table-active');
|
||||
|
||||
document.getElementById('detail-id').textContent = `#${id}`;
|
||||
document.getElementById('detail-body').innerHTML = '<div class="text-center py-3"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
|
||||
const r = await fetch(`api/lab/get_domicilios.php?id=${id}&no_stats=1`);
|
||||
const d = await r.json();
|
||||
const dom = d.domicilio;
|
||||
if (!dom) return;
|
||||
|
||||
// Botones de estado
|
||||
const SIGUIENTES = {
|
||||
programado: [['confirmado','Confirmar','info'],['cancelado','Cancelar','danger']],
|
||||
confirmado: [['en_camino','En camino','primary'],['cancelado','Cancelar','danger']],
|
||||
en_camino: [['en_domicilio','En domicilio','warning']],
|
||||
en_domicilio:[['completado','Completar','success']],
|
||||
};
|
||||
const btns = (SIGUIENTES[dom.estado]||[]).map(([e,lbl,col]) =>
|
||||
`<button class="btn btn-sm btn-${col}" onclick="cambiarEstado(${dom.id},'${e}')">
|
||||
<i class="fas fa-arrow-right me-1"></i>${lbl}
|
||||
</button>`
|
||||
).join('');
|
||||
|
||||
document.getElementById('detail-body').innerHTML = `
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<dl class="row small">
|
||||
<dt class="col-5 text-muted">Paciente</dt><dd class="col-7 fw-semibold">${esc(dom.paciente_nombre)}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(dom.paciente_telefono||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Dirección</dt><dd class="col-7">${esc(dom.direccion)}</dd>
|
||||
<dt class="col-5 text-muted">Barrio</dt><dd class="col-7">${esc(dom.barrio||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Indicaciones</dt><dd class="col-7">${esc(dom.indicaciones_dir||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Fecha</dt><dd class="col-7">${esc(dom.fecha_programada)} ${esc(dom.hora_programada||'')}</dd>
|
||||
<dt class="col-5 text-muted">Servicio</dt><dd class="col-7">${esc(dom.tipo_servicio||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Tipo cliente</dt><dd class="col-7">
|
||||
${dom.tipo_cliente === 'seguro'
|
||||
? `<span class="badge bg-info text-dark"><i class="fas fa-shield-alt me-1"></i>Seguro${dom.seguro_nombre ? ' — '+esc(dom.seguro_nombre) : ''}</span>`
|
||||
: `<span class="badge bg-secondary"><i class="fas fa-wallet me-1"></i>Particular</span>`}
|
||||
</dd>
|
||||
${dom.autorizacion ? `<dt class="col-5 text-muted">Autorización</dt><dd class="col-7">${esc(dom.autorizacion)}</dd>` : ''}
|
||||
${dom.copago_laboratorio ? `<dt class="col-5 text-muted">Copago lab.</dt><dd class="col-7">$${Number(dom.copago_laboratorio).toLocaleString('es-CO')}</dd>` : ''}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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>
|
||||
</div>
|
||||
<!-- Enfermera asignada -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted text-uppercase">Enfermera</label>
|
||||
${dom.enfermera_nombre
|
||||
? `<div class="d-flex align-items-center gap-2">
|
||||
<span><i class="fas fa-user-nurse text-success me-1"></i>${esc(dom.enfermera_nombre)}</span>
|
||||
<button class="btn btn-xs btn-outline-secondary py-0 px-2" onclick="mostrarAsignacion(${dom.id})">Reasignar</button>
|
||||
</div>`
|
||||
: `<div class="alert alert-warning py-2 small d-flex align-items-center justify-content-between mb-0">
|
||||
<span><i class="fas fa-exclamation-triangle me-2"></i>Sin enfermera asignada</span>
|
||||
<button class="btn btn-sm btn-warning" onclick="mostrarAsignacion(${dom.id})"><i class="fas fa-user-plus me-1"></i>Asignar</button>
|
||||
</div>`}
|
||||
</div>
|
||||
<!-- Orden médica -->
|
||||
${dom.orden_id ? `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted text-uppercase">Orden médica</label>
|
||||
<a href="lab_ordenes.php?id=${dom.orden_id}" class="d-block small">
|
||||
<i class="fas fa-file-medical me-1"></i>Ver orden #${dom.orden_id}
|
||||
</a>
|
||||
</div>` : ''}
|
||||
<!-- Botones de flujo -->
|
||||
<div class="d-flex gap-2 flex-wrap">${btns}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label small text-muted text-uppercase">Exámenes de la orden</label>
|
||||
<div class="bg-light p-2 rounded small">${esc(dom.examenes_solicitados||'—')}</div>
|
||||
</div>
|
||||
</div>
|
||||
${(dom.valor_domicilio || dom.valor_copago) ? `
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div class="d-flex gap-3 flex-wrap p-2 rounded" style="background:#d1fae5;border:1px solid #6ee7b7">
|
||||
<span class="small fw-semibold text-success"><i class="fas fa-cash-register me-1"></i>Cobro al cliente:</span>
|
||||
${dom.valor_domicilio ? `<span class="small">Domicilio: <strong>$${Number(dom.valor_domicilio).toLocaleString('es-CO')}</strong></span>` : ''}
|
||||
${dom.valor_copago ? `<span class="small">Copago: <strong>$${Number(dom.valor_copago).toLocaleString('es-CO')}</strong></span>` : ''}
|
||||
${ (dom.valor_domicilio && dom.valor_copago) ? `<span class="small fw-bold text-success">Total: $${(Number(dom.valor_domicilio)+Number(dom.valor_copago)).toLocaleString('es-CO')}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
${dom.notas_admin ? `
|
||||
<div class="row mt-2">
|
||||
<div class="col-12">
|
||||
<label class="form-label small text-muted text-uppercase">Notas internas</label>
|
||||
<div class="bg-light p-2 rounded small">${esc(dom.notas_admin)}</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function cerrarDetalle() {
|
||||
domSeleccionado = null;
|
||||
document.querySelectorAll('.dom-row').forEach(r => r.classList.remove('table-active'));
|
||||
document.getElementById('detail-id').textContent = '';
|
||||
document.getElementById('detail-body').innerHTML = '<div class="text-center py-5 text-muted"><i class="fas fa-arrow-left me-2"></i>Selecciona un domicilio</div>';
|
||||
}
|
||||
|
||||
// ── Asignación rápida ──────────────────────────────────────────────────────
|
||||
async function mostrarAsignacion(domId) {
|
||||
const re = await fetch('api/lab/get_enfermeras.php');
|
||||
const de = await re.json();
|
||||
const opts = (de.data||[]).map(e => `<option value="${e.id}">${esc(e.nombre_completo)} (${e.domicilios_hoy||0} hoy)</option>`).join('');
|
||||
|
||||
if (!confirm('¿Asignar enfermera?')) return;
|
||||
|
||||
const sel = prompt(`Selecciona el ID de la enfermera:\n${(de.data||[]).map(e=>`${e.id}: ${e.nombre_completo}`).join('\n')}`);
|
||||
if (!sel) return;
|
||||
|
||||
const rr = await fetch('api/lab/save_asignacion.php', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ domicilio_id: domId, enfermera_id: parseInt(sel) }),
|
||||
});
|
||||
const dd = await rr.json();
|
||||
mostrarToast(dd.success ? dd.message : (dd.error||'Error'), dd.success?'success':'danger');
|
||||
if (dd.success) { cargarLista(paginaActual); verDomicilio(domId); }
|
||||
}
|
||||
|
||||
// ── Cambio de estado ───────────────────────────────────────────────────────
|
||||
async function cambiarEstado(id, estado) {
|
||||
if (!confirm(`¿Cambiar estado a "${estado}"?`)) return;
|
||||
const r = await fetch('api/lab/save_domicilio.php', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ id, solo_estado:true, nuevo_estado:estado }),
|
||||
});
|
||||
const d = await r.json();
|
||||
mostrarToast(d.success ? d.message : (d.error||'Error'), d.success?'success':'danger');
|
||||
if (d.success) { cargarLista(paginaActual); verDomicilio(id); }
|
||||
}
|
||||
|
||||
// ── Formulario nuevo ───────────────────────────────────────────────────────
|
||||
function abrirFormulario() {
|
||||
document.getElementById('form-domicilio').reset();
|
||||
document.getElementById('dom-id').value = '';
|
||||
document.getElementById('dom-fecha').value = document.getElementById('filtro-fecha').value || '<?= date('Y-m-d') ?>';
|
||||
modal.show();
|
||||
|
||||
// Autocompletar paciente desde parámetros URL
|
||||
<?php if ($pacIdParam): ?>
|
||||
document.getElementById('dom-paciente-id').value = '<?= $pacIdParam ?>';
|
||||
document.getElementById('dom-paciente-nombre').value = 'Paciente #<?= $pacIdParam ?>';
|
||||
<?php endif; ?>
|
||||
}
|
||||
|
||||
async function guardarDomicilio() {
|
||||
const form = document.getElementById('form-domicilio');
|
||||
if (!form.checkValidity()) { form.reportValidity(); return; }
|
||||
if (!document.getElementById('dom-paciente-id').value) {
|
||||
alert('Debes seleccionar un paciente'); return;
|
||||
}
|
||||
const datos = Object.fromEntries(new FormData(form).entries());
|
||||
if (!datos.id) delete datos.id;
|
||||
if (!datos.orden_id) delete datos.orden_id;
|
||||
|
||||
const r = await fetch('api/lab/save_domicilio.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(datos),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
modal.hide();
|
||||
mostrarToast(d.message, 'success');
|
||||
cargarLista(paginaActual);
|
||||
} else {
|
||||
mostrarToast(d.error||'Error', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Autocompletado de paciente
|
||||
let pacTimer;
|
||||
document.getElementById('dom-paciente-nombre').addEventListener('input', function() {
|
||||
clearTimeout(pacTimer);
|
||||
const v = this.value.trim();
|
||||
if (v.length < 2) { document.getElementById('pac-suggest').style.display='none'; return; }
|
||||
pacTimer = setTimeout(async () => {
|
||||
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(v)}&limit=8`);
|
||||
const d = await r.json();
|
||||
const list = document.getElementById('pac-suggest');
|
||||
if (!d.data?.length) { list.style.display='none'; return; }
|
||||
list.innerHTML = d.data.map(p =>
|
||||
`<button type="button" class="list-group-item list-group-item-action small py-1"
|
||||
onclick="selPaciente(${p.id},'${esc(p.nombre_completo)}')">${esc(p.nombre_completo)} — ${esc(p.numero_documento||p.telefono||'')}</button>`
|
||||
).join('');
|
||||
list.style.display = 'block';
|
||||
}, 300);
|
||||
});
|
||||
|
||||
function selPaciente(id, nombre) {
|
||||
document.getElementById('dom-paciente-id').value = id;
|
||||
document.getElementById('dom-paciente-nombre').value= nombre;
|
||||
document.getElementById('pac-suggest').style.display = 'none';
|
||||
}
|
||||
|
||||
function verHoy() {
|
||||
document.getElementById('filtro-fecha').value = '<?= date('Y-m-d') ?>';
|
||||
cargarLista();
|
||||
}
|
||||
|
||||
// ── Utils ──────────────────────────────────────────────────────────────────
|
||||
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||
function mostrarToast(msg, tipo='success') {
|
||||
const div = document.createElement('div');
|
||||
div.className = `alert alert-${tipo} alert-dismissible position-fixed bottom-0 end-0 m-3`;
|
||||
div.style.zIndex = 9999;
|
||||
div.innerHTML = `${esc(msg)}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||||
document.body.appendChild(div);
|
||||
setTimeout(() => div.remove(), 4000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
/**
|
||||
* Gestión de Enfermeras — Módulo Administrativo de Laboratorio
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Enfermeras — Módulo Lab</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
<style>
|
||||
.enf-card { border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,.07); cursor: pointer; transition: transform .15s, box-shadow .15s; }
|
||||
.enf-card:hover { transform: translateY(-2px); box-shadow: 0 4px 20px rgba(0,0,0,.12); }
|
||||
.enf-card.selected { border: 2px solid var(--bs-primary); }
|
||||
.agenda-row { font-size: .85rem; padding: 6px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.agenda-row:last-child { border-bottom: none; }
|
||||
.carga-bar { height: 8px; border-radius: 4px; background: #e9ecef; }
|
||||
.carga-bar-fill { height: 8px; border-radius: 4px; background: var(--bs-primary); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_ordenes.php" class="nav-link"><i class="fas fa-file-medical"></i> Órdenes</a></li>
|
||||
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<li><a href="lab_enfermeras.php" class="nav-link active"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
|
||||
<li><a href="lab_formularios.php" class="nav-link"><i class="fas fa-wpforms"></i> Formularios</a></li>
|
||||
<li><a href="lab_reportes.php" class="nav-link"><i class="fas fa-chart-bar"></i> Reportes</a></li>
|
||||
<li><a href="lab_configuracion.php" class="nav-link"><i class="fas fa-sliders-h"></i> Configuración</a></li>
|
||||
<li><a href="lab_usuarios.php" class="nav-link"><i class="fas fa-users-cog"></i> Usuarios</a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
|
||||
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="content-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h1><i class="fas fa-user-nurse text-primary"></i> Enfermeras</h1>
|
||||
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="form-check form-switch my-auto me-2">
|
||||
<input class="form-check-input" type="checkbox" id="toggle-inactivas" onchange="cargarEnfermeras()">
|
||||
<label class="form-check-label small" for="toggle-inactivas">Ver inactivas</label>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="abrirFormulario()">
|
||||
<i class="fas fa-plus me-1"></i> Nueva Enfermera
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="row g-3">
|
||||
<!-- Tarjetas de enfermeras -->
|
||||
<div class="col-lg-6">
|
||||
<div class="row g-3" id="tarjetas-container">
|
||||
<div class="col-12 text-center py-4 text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel de agenda -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white border-0 d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0 fw-semibold" id="agenda-titulo">
|
||||
<i class="fas fa-calendar-day me-2 text-primary"></i>Agenda del día
|
||||
</h6>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input type="date" class="form-control form-control-sm" id="agenda-fecha"
|
||||
value="<?= date('Y-m-d') ?>" onchange="actualizarAgenda()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" id="agenda-body">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-user-nurse fa-2x mb-3 opacity-30"></i><br>
|
||||
Selecciona una enfermera para ver su agenda
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal Formulario Enfermera -->
|
||||
<div class="modal fade" id="modalEnfermera" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-user-nurse me-2"></i><span id="modal-titulo">Nueva Enfermera</span></h5>
|
||||
<button class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="form-enfermera">
|
||||
<input type="hidden" name="id" id="enf-id">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Nombre completo <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="nombre_completo" id="enf-nombre" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Tipo doc.</label>
|
||||
<select class="form-select" name="tipo_documento" id="enf-tipo-doc">
|
||||
<option value="CC">CC</option><option value="CE">CE</option>
|
||||
<option value="TI">TI</option><option value="PA">PA</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Número de documento <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="numero_documento" id="enf-doc" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Teléfono <span class="text-danger">*</span></label>
|
||||
<input type="tel" class="form-control" name="telefono" id="enf-tel" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Teléfono alternativo</label>
|
||||
<input type="tel" class="form-control" name="telefono_alt" id="enf-tel2">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" name="email" id="enf-email">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Zona / Sector habitual</label>
|
||||
<input type="text" class="form-control" name="zona" id="enf-zona" placeholder="Ej: Norte, Centro, Laureles…">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notas</label>
|
||||
<textarea class="form-control" name="notas" id="enf-notas" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between">
|
||||
<div id="btn-desactivar-cont"></div>
|
||||
<div>
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-primary" onclick="guardarEnfermera()"><i class="fas fa-save me-1"></i> Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ MODAL: Acceso al sistema ═══════════════════════════════════════ -->
|
||||
<div class="modal fade" id="modalAccesoEnfermero" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header text-white" style="background:#6f42c1">
|
||||
<h6 class="modal-title"><i class="fas fa-key me-2"></i>Acceso al sistema</h6>
|
||||
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-3" id="acceso-subtitulo"></p>
|
||||
<input type="hidden" id="acceso-enf-id">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Usuario <span class="text-danger">*</span></label>
|
||||
<input type="text" id="acceso-username" class="form-control form-control-sm"
|
||||
placeholder="ej: enfermera1" autocomplete="off">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small" id="acceso-pwd-label">Contraseña <span class="text-danger">*</span></label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="password" id="acceso-password" class="form-control"
|
||||
placeholder="Mínimo 6 caracteres" autocomplete="new-password">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="document.getElementById('acceso-password').type = document.getElementById('acceso-password').type==='password'?'text':'password'">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text d-none" id="acceso-pwd-hint">Dejar vacío para no cambiar la contraseña actual.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-sm text-white" style="background:#6f42c1" onclick="guardarAcceso()">
|
||||
<i class="fas fa-save me-1"></i>Guardar acceso
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const modal = new bootstrap.Modal('#modalEnfermera');
|
||||
const modalAcceso = new bootstrap.Modal('#modalAccesoEnfermero');
|
||||
let enfSeleccionada = null;
|
||||
const COLOR_DOM = { programado:'secondary', confirmado:'info', en_camino:'primary', en_domicilio:'warning', completado:'success', cancelado:'danger' };
|
||||
|
||||
// ── Lista de enfermeras ────────────────────────────────────────────────────
|
||||
async function cargarEnfermeras() {
|
||||
const todas = document.getElementById('toggle-inactivas').checked ? '1' : '0';
|
||||
const r = await fetch(`api/lab/get_enfermeras.php?todas=${todas}`);
|
||||
const d = await r.json();
|
||||
const cont = document.getElementById('tarjetas-container');
|
||||
|
||||
if (!d.data?.length) {
|
||||
cont.innerHTML = '<div class="col-12 text-center text-muted py-4">Sin enfermeras registradas</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
cont.innerHTML = d.data.map(e => {
|
||||
const hoy = e.domicilios_hoy || 0;
|
||||
return `
|
||||
<div class="col-md-6">
|
||||
<div class="enf-card card ${enfSeleccionada===e.id?'selected':''}" onclick="selEnfermera(${e.id},'${esc(e.nombre_completo)}')" id="card-${e.id}">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="rounded-circle bg-success bg-opacity-10 d-flex align-items-center justify-content-center"
|
||||
style="width:44px;height:44px;flex-shrink:0">
|
||||
<i class="fas fa-user-nurse text-success"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold">${esc(e.nombre_completo)} ${!e.is_active?'<span class="badge bg-secondary ms-1">inactiva</span>':''}</div>
|
||||
<small class="text-muted">${esc(e.zona||e.telefono||'—')}</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="fw-bold text-primary">${hoy}</div>
|
||||
<small class="text-muted">hoy</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="carga-bar">
|
||||
<div class="carga-bar-fill" style="width:${Math.min(hoy*12,100)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-1 mt-2">
|
||||
<button class="btn btn-xs btn-outline-secondary py-0 px-2" title="Editar" onclick="event.stopPropagation();editarEnfermera(${e.id})">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn btn-xs py-0 px-2" title="Acceso al sistema"
|
||||
style="background:#6f42c1;color:#fff;border:none"
|
||||
onclick="event.stopPropagation();abrirAcceso(${e.id},'${esc(e.nombre_completo)}','${esc(e.username_acceso||'')}')">
|
||||
<i class="fas fa-key"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Seleccionar enfermera → ver agenda ────────────────────────────────────
|
||||
function selEnfermera(id, nombre) {
|
||||
enfSeleccionada = id;
|
||||
document.querySelectorAll('.enf-card').forEach(c => c.classList.remove('selected'));
|
||||
const card = document.getElementById(`card-${id}`);
|
||||
if (card) card.classList.add('selected');
|
||||
document.getElementById('agenda-titulo').innerHTML =
|
||||
`<i class="fas fa-calendar-day me-2 text-primary"></i>Agenda de <strong>${esc(nombre)}</strong>`;
|
||||
actualizarAgenda();
|
||||
}
|
||||
|
||||
async function actualizarAgenda() {
|
||||
if (!enfSeleccionada) return;
|
||||
const fecha = document.getElementById('agenda-fecha').value || '<?= date('Y-m-d') ?>';
|
||||
const r = await fetch(`api/lab/get_enfermeras.php?id=${enfSeleccionada}&fecha=${fecha}`);
|
||||
const d = await r.json();
|
||||
const agenda = d.enfermera?.agenda || [];
|
||||
|
||||
const el = document.getElementById('agenda-body');
|
||||
|
||||
if (!agenda.length) {
|
||||
el.innerHTML = '<div class="text-center py-4 text-muted"><i class="fas fa-calendar-times me-2"></i>Sin domicilios asignados para esta fecha</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = agenda.map((a, i) => `
|
||||
<div class="agenda-row d-flex align-items-start gap-3">
|
||||
<div class="text-center" style="min-width:46px">
|
||||
<div class="fw-bold text-primary">${a.hora_programada || '—'}</div>
|
||||
<div class="text-muted" style="font-size:.7rem">#${i+1}</div>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold">${esc(a.paciente_nombre)}</div>
|
||||
<small class="text-muted">${esc(a.direccion||'')} ${a.barrio?`(${esc(a.barrio)})`:''}
|
||||
${a.paciente_telefono?`<br><i class="fas fa-phone me-1"></i>${esc(a.paciente_telefono)}`:''}
|
||||
${a.tipo_servicio?`<br><i class="fas fa-vial me-1"></i>${esc(a.tipo_servicio)}`:''}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge bg-${COLOR_DOM[a.domicilio_estado]||'secondary'}">${esc(a.domicilio_estado)}</span>
|
||||
<br>
|
||||
<a href="lab_domicilios.php?id=${a.domicilio_id}" class="btn btn-xs btn-outline-primary py-0 px-1 mt-1">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ── Formulario ─────────────────────────────────────────────────────────────
|
||||
function abrirFormulario(e = null) {
|
||||
document.getElementById('form-enfermera').reset();
|
||||
document.getElementById('enf-id').value = e ? e.id : '';
|
||||
document.getElementById('modal-titulo').textContent = e ? 'Editar Enfermera' : 'Nueva Enfermera';
|
||||
document.getElementById('btn-desactivar-cont').innerHTML = '';
|
||||
|
||||
if (e) {
|
||||
document.getElementById('enf-nombre').value = e.nombre_completo || '';
|
||||
document.getElementById('enf-doc').value = e.numero_documento || '';
|
||||
document.getElementById('enf-tipo-doc').value = e.tipo_documento || 'CC';
|
||||
document.getElementById('enf-tel').value = e.telefono || '';
|
||||
document.getElementById('enf-tel2').value = e.telefono_alt || '';
|
||||
document.getElementById('enf-email').value = e.email || '';
|
||||
document.getElementById('enf-zona').value = e.zona || '';
|
||||
document.getElementById('enf-notas').value = e.notas || '';
|
||||
|
||||
if (e.is_active) {
|
||||
document.getElementById('btn-desactivar-cont').innerHTML =
|
||||
`<button class="btn btn-outline-danger btn-sm" onclick="desactivarEnfermera(${e.id})">
|
||||
<i class="fas fa-ban me-1"></i> Desactivar
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async function editarEnfermera(id) {
|
||||
const r = await fetch(`api/lab/get_enfermeras.php?id=${id}`);
|
||||
const d = await r.json();
|
||||
if (d.enfermera) abrirFormulario(d.enfermera);
|
||||
}
|
||||
|
||||
async function guardarEnfermera() {
|
||||
const form = document.getElementById('form-enfermera');
|
||||
if (!form.checkValidity()) { form.reportValidity(); return; }
|
||||
const datos = Object.fromEntries(new FormData(form).entries());
|
||||
if (!datos.id) delete datos.id;
|
||||
|
||||
const r = await fetch('api/lab/save_enfermera.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(datos),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
modal.hide();
|
||||
mostrarToast(d.message, 'success');
|
||||
cargarEnfermeras();
|
||||
} else {
|
||||
mostrarToast(d.error||'Error', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function desactivarEnfermera(id) {
|
||||
if (!confirm('¿Desactivar esta enfermera?')) return;
|
||||
const r = await fetch('api/lab/save_enfermera.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ id, is_active: 0 }),
|
||||
});
|
||||
const d = await r.json();
|
||||
modal.hide();
|
||||
mostrarToast(d.success ? d.message : (d.error||'Error'), d.success?'warning':'danger');
|
||||
cargarEnfermeras();
|
||||
}
|
||||
|
||||
// ── Utils ──────────────────────────────────────────────────────────────────
|
||||
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||
function mostrarToast(msg, tipo='success') {
|
||||
const div = document.createElement('div');
|
||||
div.className = `alert alert-${tipo} alert-dismissible position-fixed bottom-0 end-0 m-3`;
|
||||
div.style.zIndex = 9999;
|
||||
div.innerHTML = `${esc(msg)}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||||
document.body.appendChild(div);
|
||||
setTimeout(()=>div.remove(), 4000);
|
||||
}
|
||||
|
||||
// ── Acceso al sistema ──────────────────────────────────────────────────────
|
||||
function abrirAcceso(enfId, nombre, usernameActual = '') {
|
||||
document.getElementById('acceso-enf-id').value = enfId;
|
||||
document.getElementById('acceso-username').value = usernameActual;
|
||||
document.getElementById('acceso-password').value = '';
|
||||
document.getElementById('acceso-subtitulo').textContent = nombre || '';
|
||||
|
||||
if (usernameActual) {
|
||||
document.getElementById('acceso-pwd-label').innerHTML = 'Nueva contraseña <small class="text-muted">(opcional)</small>';
|
||||
document.getElementById('acceso-pwd-hint').classList.remove('d-none');
|
||||
} else {
|
||||
document.getElementById('acceso-pwd-label').innerHTML = 'Contraseña <span class="text-danger">*</span>';
|
||||
document.getElementById('acceso-pwd-hint').classList.add('d-none');
|
||||
}
|
||||
modalAcceso.show();
|
||||
}
|
||||
|
||||
async function guardarAcceso() {
|
||||
const enfId = document.getElementById('acceso-enf-id').value;
|
||||
const username = document.getElementById('acceso-username').value.trim();
|
||||
const password = document.getElementById('acceso-password').value;
|
||||
|
||||
if (!username) { mostrarToast('El usuario es obligatorio', 'danger'); return; }
|
||||
|
||||
const r = await fetch('api/lab/create_enfermero_user.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enfermera_id: parseInt(enfId), username, password }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
modalAcceso.hide();
|
||||
mostrarToast(d.message || 'Acceso guardado', 'success');
|
||||
} else {
|
||||
mostrarToast(d.error || 'Error al guardar', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
cargarEnfermeras();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,820 @@
|
||||
<?php
|
||||
/**
|
||||
* lab_formularios.php — Módulo de Formularios
|
||||
* Admin: crea y diseña formularios drag & drop
|
||||
* Enfermero: solo puede ver la lista y enviar formularios pre-llenados
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: login.php'); exit; }
|
||||
if (isEnfermero() && !enfermeraId()) { header('Location: enfermero_portal.php'); exit; }
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? 'Usuario';
|
||||
$esAdmin = !isEnfermero();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Formularios — Módulo Lab</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
<style>
|
||||
/* ── Tarjeta formulario ─────────────────────────── */
|
||||
.form-card { border-radius:10px; border:1px solid #dee2e6; background:#fff;
|
||||
transition:box-shadow .2s; }
|
||||
.form-card:hover { box-shadow:0 3px 12px rgba(0,0,0,.1); }
|
||||
|
||||
/* ── Colores estado envío ───────────────────────── */
|
||||
.badge-pendiente { background:#6c757d; }
|
||||
.badge-completado { background:#0d6efd; }
|
||||
.badge-firmado { background:#198754; }
|
||||
.badge-expirado { background:#dc3545; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
|
||||
<ul class="sidebar-menu">
|
||||
<?php if ($esAdmin): ?>
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_ordenes.php" class="nav-link"><i class="fas fa-file-medical"></i> Órdenes</a></li>
|
||||
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<li><a href="lab_enfermeras.php" class="nav-link"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
|
||||
<li><a href="lab_formularios.php" class="nav-link active"><i class="fas fa-wpforms"></i> Formularios</a></li>
|
||||
<li><a href="lab_reportes.php" class="nav-link"><i class="fas fa-chart-bar"></i> Reportes</a></li>
|
||||
<li><a href="lab_configuracion.php" class="nav-link"><i class="fas fa-sliders-h"></i> Configuración</a></li>
|
||||
<li><a href="lab_usuarios.php" class="nav-link"><i class="fas fa-users-cog"></i> Usuarios</a></li>
|
||||
<?php else: ?>
|
||||
<li><a href="enfermero_portal.php" class="nav-link"><i class="fas fa-calendar-day"></i> Mi Agenda</a></li>
|
||||
<li><a href="lab_formularios.php" class="nav-link active"><i class="fas fa-wpforms"></i> Formularios</a></li>
|
||||
<?php endif; ?>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="<?= $esAdmin ? 'index.php' : 'enfermero_portal.php' ?>" class="nav-link"><i class="fas fa-arrow-left"></i> Volver</a></li>
|
||||
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="content-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h1><i class="fas fa-wpforms text-primary"></i> Formularios</h1>
|
||||
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
|
||||
</div>
|
||||
<?php if ($esAdmin): ?>
|
||||
<button class="btn btn-primary btn-sm" onclick="builder.nuevo()">
|
||||
<i class="fas fa-plus me-1"></i> Nuevo Formulario
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<!-- ── TABS ─────────────────────────────────────────────────── -->
|
||||
<ul class="nav nav-tabs mb-4" id="formTabs">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="#" onclick="tabs.mostrar('lista')">
|
||||
<i class="fas fa-list me-1"></i>Mis Formularios
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="tabs.mostrar('envios')">
|
||||
<i class="fas fa-paper-plane me-1"></i>Envíos
|
||||
<span id="badge-envios" class="badge bg-secondary ms-1" style="display:none"></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- ── LISTA ──────────────────────────────────────────────── -->
|
||||
<div id="panel-lista">
|
||||
<div class="row g-3" id="formularios-grid">
|
||||
<div class="col-12 text-center py-5 text-muted">
|
||||
<div class="spinner-border spinner-border-sm"></div> Cargando…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── ENVÍOS ─────────────────────────────────────────────── -->
|
||||
<div id="panel-envios" style="display:none">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle small" id="tabla-envios">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Formulario</th>
|
||||
<th>Paciente</th>
|
||||
<th>Estado</th>
|
||||
<th>Enviado</th>
|
||||
<th>Expira</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-envios">
|
||||
<tr><td colspan="6" class="text-center text-muted py-4">Cargando…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Builder movido a lab_formulario_builder.php (ventana dedicada) -->
|
||||
|
||||
<!-- ═══════════════════ MODAL ENVIAR ═══════════════════════════════ -->
|
||||
<div class="modal fade" id="modalEnviar" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-success text-white py-2">
|
||||
<h6 class="modal-title mb-0">
|
||||
<i class="fas fa-paper-plane me-1"></i>Enviar formulario al cliente
|
||||
</h6>
|
||||
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="env-form-id">
|
||||
|
||||
<!-- Paso 1: selección paciente/domicilio -->
|
||||
<div id="env-paso1">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="fw-semibold small">Paciente</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="env-pac-busq" class="form-control"
|
||||
placeholder="Buscar paciente…" autocomplete="off">
|
||||
<button class="btn btn-outline-secondary" onclick="envio.buscarPaciente()">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="env-pac-list" class="list-group shadow mt-1"
|
||||
style="position:relative;z-index:1060;max-height:150px;overflow-y:auto;display:none"></div>
|
||||
<div id="env-pac-card" class="alert alert-success py-2 mt-2 d-none">
|
||||
<i class="fas fa-user-check me-1"></i>
|
||||
<span id="env-pac-nombre"></span>
|
||||
<button class="btn-close float-end btn-sm" onclick="envio.limpiarPaciente()"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pre-llenado dinámico basado en el esquema -->
|
||||
<div class="col-12" id="env-prefill-cont" style="display:none">
|
||||
<p class="fw-semibold small text-secondary mb-2">
|
||||
<i class="fas fa-fill-drip me-1"></i>PRE-LLENAR CAMPOS
|
||||
<small class="text-muted fw-normal">(el cliente solo podrá firmar)</small>
|
||||
</p>
|
||||
<div id="env-prefill-fields" class="row g-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label small">Enviar vía</label>
|
||||
<div class="d-flex gap-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="env-via"
|
||||
id="via-wa" value="whatsapp" checked>
|
||||
<label class="form-check-label small" for="via-wa">
|
||||
<i class="fab fa-whatsapp text-success me-1"></i>WhatsApp
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="env-via"
|
||||
id="via-link" value="link">
|
||||
<label class="form-check-label small" for="via-link">
|
||||
<i class="fas fa-link me-1"></i>Solo link
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paso 2: resultado / link generado -->
|
||||
<div id="env-paso2" style="display:none">
|
||||
<div class="text-center py-3">
|
||||
<div style="font-size:2.5rem">✅</div>
|
||||
<h6 class="mt-2">¡Link generado!</h6>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Link del formulario</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control form-control-sm" id="env-link-url" readonly>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="envio.copiarLink()">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" id="env-wa-cont">
|
||||
<label class="form-label small fw-semibold">Mensaje de WhatsApp listo</label>
|
||||
<textarea class="form-control form-control-sm" id="env-wa-msg"
|
||||
rows="5" readonly></textarea>
|
||||
<div class="mt-2">
|
||||
<a id="env-wa-btn" href="#" target="_blank"
|
||||
class="btn btn-success btn-sm w-100">
|
||||
<i class="fab fa-whatsapp me-1"></i>Abrir WhatsApp y enviar
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<div id="env-footer1">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-success btn-sm" onclick="envio.generar()">
|
||||
<i class="fas fa-paper-plane me-1"></i>Generar link
|
||||
</button>
|
||||
</div>
|
||||
<div id="env-footer2" style="display:none">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="envio.otroEnvio()">
|
||||
<i class="fas fa-plus me-1"></i>Enviar a otro paciente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════ MODAL VER RESPUESTA ══════════════════════════ -->
|
||||
<div class="modal fade" id="modalRespuesta" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title mb-0"><i class="fas fa-file-alt me-1"></i>Respuesta del cliente</h6>
|
||||
<button class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="resp-body"></div>
|
||||
<div class="modal-footer py-2">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
|
||||
<a id="btn-pdf-link" href="#" target="_blank" class="btn btn-danger btn-sm">
|
||||
<i class="fas fa-file-pdf me-1"></i>Ver / Descargar PDF
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="verRespuesta.imprimir()">
|
||||
<i class="fas fa-print me-1"></i>Imprimir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// CONFIGURACIÓN
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const ES_ADMIN = <?= $esAdmin ? 'true' : 'false' ?>;
|
||||
|
||||
// Tipos de campo disponibles
|
||||
const TIPOS_CAMPO = [
|
||||
{ tipo:'texto', label:'Texto corto', icon:'fa-font', linked:false },
|
||||
{ tipo:'textarea', label:'Texto largo', icon:'fa-align-left', linked:false },
|
||||
{ tipo:'numero', label:'Número', icon:'fa-hashtag', linked:false },
|
||||
{ tipo:'fecha', label:'Fecha', icon:'fa-calendar', linked:false },
|
||||
{ tipo:'hora', label:'Hora', icon:'fa-clock', linked:false },
|
||||
{ tipo:'select', label:'Lista desplegable',icon:'fa-list', linked:false },
|
||||
{ tipo:'radio', label:'Selección única', icon:'fa-dot-circle', linked:false },
|
||||
{ tipo:'checkbox', label:'Múltiple opción', icon:'fa-check-square', linked:false },
|
||||
{ tipo:'firma', label:'Firma digital', icon:'fa-signature', linked:false },
|
||||
{ tipo:'separador', label:'Separador / Título',icon:'fa-minus', linked:false },
|
||||
];
|
||||
|
||||
// Campos vinculados (auto-llenados desde el paciente)
|
||||
const TIPOS_LINKED = [
|
||||
{ tipo:'linked', key:'nombre_completo', label:'Nombre completo (paciente)', icon:'fa-user' },
|
||||
{ tipo:'linked', key:'numero_documento',label:'Número de documento', icon:'fa-id-card' },
|
||||
{ tipo:'linked', key:'tipo_documento', label:'Tipo de documento', icon:'fa-id-badge' },
|
||||
{ tipo:'linked', key:'fecha_nacimiento',label:'Fecha de nacimiento', icon:'fa-birthday-cake' },
|
||||
{ tipo:'linked', key:'telefono', label:'Teléfono', icon:'fa-phone' },
|
||||
{ tipo:'linked', key:'email', label:'Email', icon:'fa-envelope' },
|
||||
{ tipo:'linked', key:'eps', label:'EPS / Aseguradora', icon:'fa-hospital' },
|
||||
{ tipo:'linked', key:'direccion', label:'Dirección', icon:'fa-map-marker-alt' },
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// ESTADO
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
let _formularios = [];
|
||||
let _globalCfg = {}; // config global del lab
|
||||
|
||||
let _bsEnviar = null;
|
||||
let _bsResp = null;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// HELPERS DOM
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const $ = id => document.getElementById(id);
|
||||
function esc(s) {
|
||||
return String(s||'').replace(/[<>&"']/g, c =>
|
||||
({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function uid() { return '_' + Math.random().toString(36).slice(2, 9); }
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// TABS
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const tabs = {
|
||||
mostrar(tab) {
|
||||
$('panel-lista').style.display = tab === 'lista' ? '' : 'none';
|
||||
$('panel-envios').style.display = tab === 'envios' ? '' : 'none';
|
||||
document.querySelectorAll('#formTabs .nav-link').forEach((a,i) =>
|
||||
a.classList.toggle('active', (tab==='lista') === (i===0))
|
||||
);
|
||||
if (tab === 'envios') envios.cargar();
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// LISTA DE FORMULARIOS
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
async function cargarFormularios() {
|
||||
const r = await fetch('api/lab/get_formularios.php');
|
||||
const d = await r.json();
|
||||
_formularios = d.data || [];
|
||||
renderFormularios();
|
||||
}
|
||||
|
||||
function renderFormularios() {
|
||||
const grid = $('formularios-grid');
|
||||
if (!_formularios.length) {
|
||||
grid.innerHTML = `<div class="col-12 text-center py-5 text-muted">
|
||||
<i class="fas fa-wpforms fa-3x mb-3 opacity-25"></i>
|
||||
<p>No hay formularios creados aún.</p>
|
||||
${ES_ADMIN ? '<button class="btn btn-primary" onclick="builder.nuevo()"><i class="fas fa-plus me-1"></i>Crear primer formulario</button>' : ''}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const CATS = { consentimiento:'⚕️',historia_clinica:'📋',autorizacion:'✍️',encuesta:'📊',otro:'📄' };
|
||||
grid.innerHTML = _formularios.map(f => `
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<div class="form-card p-3 h-100">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="badge bg-light text-dark border">${CATS[f.categoria]||'📄'} ${esc(f.categoria)}</span>
|
||||
${f.requiere_firma ? '<span class="badge bg-warning text-dark small"><i class="fas fa-signature me-1"></i>Firma req.</span>' : ''}
|
||||
</div>
|
||||
<h6 class="fw-bold mb-1">${esc(f.nombre)}</h6>
|
||||
${f.descripcion ? `<p class="small text-muted mb-2">${esc(f.descripcion)}</p>` : ''}
|
||||
<div class="d-flex gap-2 small text-muted mb-3">
|
||||
<span title="Envíos"><i class="fas fa-paper-plane me-1"></i>${f.total_envios||0}</span>
|
||||
<span title="Completados"><i class="fas fa-check-circle me-1 text-success"></i>${f.total_completados||0}</span>
|
||||
<span class="ms-auto">v${f.version}</span>
|
||||
</div>
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-success flex-grow-1"
|
||||
onclick="envio.abrir(${f.id})">
|
||||
<i class="fas fa-paper-plane me-1"></i>Enviar
|
||||
</button>
|
||||
${ES_ADMIN ? `
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="builder.editar(${f.id})"
|
||||
title="Editar diseño"><i class="fas fa-edit"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="confirmarBorrar(${f.id},'${esc(f.nombre)}')"
|
||||
title="Eliminar"><i class="fas fa-trash"></i></button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function confirmarBorrar(id, nombre) {
|
||||
if (!confirm(`¿Eliminar el formulario "${nombre}"?\nLos envíos existentes se conservarán.`)) return;
|
||||
const r = await fetch('api/lab/save_formulario.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ id, borrar: true }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) { cargarFormularios(); } else { alert(d.error); }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// BUILDER (drag & drop)
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const builder = {
|
||||
|
||||
_win: null,
|
||||
|
||||
_abrirVentana(url) {
|
||||
// Reusar ventana si ya está abierta
|
||||
if (this._win && !this._win.closed) {
|
||||
this._win.location.href = url;
|
||||
this._win.focus();
|
||||
} else {
|
||||
this._win = window.open(
|
||||
url,
|
||||
'lab_builder',
|
||||
'width=1440,height=900,resizable=yes,scrollbars=yes'
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
nuevo() {
|
||||
if (!ES_ADMIN) return;
|
||||
this._abrirVentana('lab_formulario_builder.php');
|
||||
},
|
||||
|
||||
editar(id) {
|
||||
if (!ES_ADMIN) return;
|
||||
this._abrirVentana(`lab_formulario_builder.php?id=${id}`);
|
||||
},
|
||||
|
||||
}; // end builder
|
||||
|
||||
// ENVÍOS (lista)
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const ESTADO_ENV = {
|
||||
pendiente: { cls:'badge-pendiente', icon:'🕐' },
|
||||
completado: { cls:'badge-completado', icon:'✅' },
|
||||
firmado: { cls:'badge-firmado', icon:'✍️' },
|
||||
expirado: { cls:'badge-expirado', icon:'⏰' },
|
||||
};
|
||||
const envios = {
|
||||
_data: [],
|
||||
async cargar() {
|
||||
const r = await fetch('api/lab/get_formularios.php?envios=1');
|
||||
const d = await r.json();
|
||||
this._data = d.data || [];
|
||||
this._render();
|
||||
},
|
||||
_render() {
|
||||
const tb = $('tbody-envios');
|
||||
const badgePend = $('badge-envios');
|
||||
const pend = this._data.filter(e => e.estado==='pendiente').length;
|
||||
if (pend) { badgePend.textContent = pend; badgePend.style.display = ''; }
|
||||
else badgePend.style.display = 'none';
|
||||
|
||||
if (!this._data.length) {
|
||||
tb.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">Sin envíos aún</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = this._data.map(e => {
|
||||
const info = ESTADO_ENV[e.estado] || {};
|
||||
return `<tr>
|
||||
<td><span class="fw-semibold">${esc(e.form_nombre)}</span>
|
||||
<br><small class="text-muted">${esc(e.categoria)}</small></td>
|
||||
<td>${esc(e.paciente_nombre || '—')}</td>
|
||||
<td><span class="badge text-white ${info.cls}">${info.icon} ${e.estado}</span></td>
|
||||
<td><small>${esc((e.created_at||'').slice(0,16).replace('T',' '))}</small></td>
|
||||
<td><small class="text-muted">${esc((e.expira_en||'').slice(0,10))}</small></td>
|
||||
<td>
|
||||
<button class="btn btn-xs btn-outline-secondary" title="Copiar link"
|
||||
onclick="envios.copiarTokenLink('${esc(e.token)}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
${['completado','firmado'].includes(e.estado)
|
||||
? `<button class="btn btn-xs btn-outline-primary ms-1" title="Ver respuesta"
|
||||
onclick="verRespuesta.abrir(${e.id})">
|
||||
<i class="fas fa-eye"></i></button>
|
||||
<a class="btn btn-xs btn-outline-danger ms-1" title="Descargar PDF"
|
||||
href="ver_formulario_enviado.php?id=${e.id}" target="_blank">
|
||||
<i class="fas fa-file-pdf"></i></a>`
|
||||
: ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
},
|
||||
copiarTokenLink(token) {
|
||||
const url = window.location.origin + window.location.pathname.replace(/\/[^/]+$/, '')
|
||||
+ `/form_cliente.php?t=${token}`;
|
||||
navigator.clipboard.writeText(url).then(() => showToast('Link copiado'));
|
||||
},
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// ENVIAR FORMULARIO (modal)
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const envio = {
|
||||
_pacienteId: null,
|
||||
_prefilled: {},
|
||||
_esquema: [],
|
||||
|
||||
async abrir(formId) {
|
||||
$('env-form-id').value = formId;
|
||||
this._pacienteId = null;
|
||||
this._prefilled = {};
|
||||
// Obtener esquema para pre-llenado
|
||||
const r = await fetch(`api/lab/get_formularios.php?id=${formId}`);
|
||||
const d = await r.json();
|
||||
this._esquema = d.formulario?.esquema_decoded || [];
|
||||
|
||||
// Reset UI
|
||||
$('env-pac-busq').value = '';
|
||||
$('env-pac-list').style.display = 'none';
|
||||
$('env-pac-card').classList.add('d-none');
|
||||
$('env-prefill-cont').style.display = 'none';
|
||||
$('env-paso1').style.display = '';
|
||||
$('env-paso2').style.display = 'none';
|
||||
$('env-footer1').style.display = '';
|
||||
$('env-footer2').style.display = 'none';
|
||||
document.getElementById('via-wa').checked = true;
|
||||
|
||||
if (!_bsEnviar) _bsEnviar = new bootstrap.Modal('#modalEnviar');
|
||||
_bsEnviar.show();
|
||||
},
|
||||
|
||||
async buscarPaciente() {
|
||||
const q = $('env-pac-busq').value.trim();
|
||||
if (!q) return;
|
||||
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(q)}&limit=6`);
|
||||
const d = await r.json();
|
||||
const list = $('env-pac-list');
|
||||
if (!d.data?.length) {
|
||||
list.innerHTML = '<a class="list-group-item text-muted disabled small py-1">Sin resultados</a>';
|
||||
} else {
|
||||
list.innerHTML = d.data.map(p =>
|
||||
`<button type="button" class="list-group-item list-group-item-action small py-2"
|
||||
data-id="${p.id}" data-nombre="${esc(p.nombre_completo)}"
|
||||
data-tel="${esc(p.telefono||'')}"
|
||||
data-doc="${esc((p.tipo_documento||'') + ' ' + (p.numero_documento||''))}"
|
||||
data-nac="${esc(p.fecha_nacimiento||'')}" data-eps="${esc(p.eps||'')}"
|
||||
data-email="${esc(p.email||'')}" data-dir="${esc(p.direccion||'')}"
|
||||
data-ciudad="${esc(p.ciudad||'')}"
|
||||
onclick="envio.selPaciente(this)">
|
||||
<strong>${esc(p.nombre_completo)}</strong>
|
||||
<span class="text-muted ms-2">${esc(p.tipo_documento||'')} ${esc(p.numero_documento||'')}</span>
|
||||
</button>`
|
||||
).join('');
|
||||
}
|
||||
list.style.display = 'block';
|
||||
},
|
||||
|
||||
selPaciente(btn) {
|
||||
this._pacienteId = +btn.dataset.id;
|
||||
$('env-pac-list').style.display = 'none';
|
||||
$('env-pac-busq').value = btn.dataset.nombre;
|
||||
$('env-pac-nombre').textContent = btn.dataset.nombre;
|
||||
$('env-pac-card').classList.remove('d-none');
|
||||
// Construir campos de pre-llenado a partir del esquema
|
||||
this._buildPrefillFields(btn.dataset);
|
||||
},
|
||||
|
||||
_buildPrefillFields(pacData) {
|
||||
// Campos editables que el enfermero puede pre-llenar (excluir estáticos y especiales)
|
||||
const editables = this._esquema.filter(c =>
|
||||
!['firma','separador','linked','parrafo','parrafo_inline','lista_marcable'].includes(c.tipo)
|
||||
&& c.label // debe tener etiqueta
|
||||
);
|
||||
if (!editables.length) { $('env-prefill-cont').style.display = 'none'; return; }
|
||||
|
||||
// Auto-llenar campos linked desde datos del paciente
|
||||
const linkedMap = {
|
||||
nombre_completo: pacData.nombre || pacData.nombre_completo || '',
|
||||
numero_documento: (pacData.doc||'').replace(/^(CC|CE|TI|PA)\s*/,''),
|
||||
tipo_documento: (pacData.doc||'').split(' ')[0] || '',
|
||||
telefono: pacData.tel || '',
|
||||
email: pacData.email|| '',
|
||||
fecha_nacimiento: pacData.nac || '',
|
||||
eps: pacData.eps || '',
|
||||
direccion: pacData.dir || '',
|
||||
};
|
||||
this._prefilled = { ...linkedMap };
|
||||
|
||||
const cont = $('env-prefill-fields');
|
||||
cont.innerHTML = editables.slice(0, 10).map(c => {
|
||||
const val = this._prefilled[c.id] || '';
|
||||
if (c.tipo === 'select') {
|
||||
const opts = (c.options||[]).map(o =>
|
||||
`<option value="${esc(o)}" ${o===val?'selected':''}>${esc(o)}</option>`
|
||||
).join('');
|
||||
return `<div class="col-md-6">
|
||||
<label class="form-label small">${esc(c.label)}</label>
|
||||
<select class="form-select form-select-sm" data-fid="${c.id}" onchange="envio._updatePre(this)">
|
||||
<option value="">— sin especificar —</option>${opts}
|
||||
</select></div>`;
|
||||
}
|
||||
return `<div class="col-md-6">
|
||||
<label class="form-label small">${esc(c.label)}</label>
|
||||
<input type="${c.tipo==='numero'?'number':c.tipo==='fecha'?'date':'text'}"
|
||||
class="form-control form-control-sm" data-fid="${c.id}"
|
||||
value="${esc(val)}" placeholder="${esc(c.placeholder||'')}"
|
||||
onchange="envio._updatePre(this)">
|
||||
</div>`;
|
||||
}).join('');
|
||||
$('env-prefill-cont').style.display = '';
|
||||
},
|
||||
|
||||
_updatePre(el) { this._prefilled[el.dataset.fid] = el.value; },
|
||||
limpiarPaciente() {
|
||||
this._pacienteId = null;
|
||||
$('env-pac-busq').value = '';
|
||||
$('env-pac-card').classList.add('d-none');
|
||||
$('env-prefill-cont').style.display = 'none';
|
||||
},
|
||||
|
||||
async generar() {
|
||||
const formId = +$('env-form-id').value;
|
||||
const via = document.querySelector('input[name="env-via"]:checked')?.value || 'link';
|
||||
const datos = {
|
||||
formulario_id: formId,
|
||||
paciente_id: this._pacienteId || null,
|
||||
datos_prefilled: this._prefilled,
|
||||
enviado_via: via,
|
||||
};
|
||||
const r = await fetch('api/lab/send_formulario.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(datos),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) { alert(d.error); return; }
|
||||
|
||||
$('env-link-url').value = d.url;
|
||||
$('env-wa-msg').value = d.mensaje_wa;
|
||||
$('env-wa-btn').href = d.whatsapp_url;
|
||||
$('env-wa-cont').style.display = via === 'whatsapp' ? '' : 'none';
|
||||
$('env-paso1').style.display = 'none';
|
||||
$('env-paso2').style.display = '';
|
||||
$('env-footer1').style.display = 'none';
|
||||
$('env-footer2').style.display = '';
|
||||
envios._data = []; // invalidar caché
|
||||
},
|
||||
|
||||
copiarLink() {
|
||||
navigator.clipboard.writeText($('env-link-url').value).then(() => showToast('Link copiado'));
|
||||
},
|
||||
|
||||
otroEnvio() {
|
||||
$('env-paso1').style.display = '';
|
||||
$('env-paso2').style.display = 'none';
|
||||
$('env-footer1').style.display = '';
|
||||
$('env-footer2').style.display = 'none';
|
||||
this.limpiarPaciente();
|
||||
},
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// VER RESPUESTA
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const verRespuesta = {
|
||||
_envioId: null,
|
||||
|
||||
async abrir(envioId) {
|
||||
this._envioId = envioId;
|
||||
const e = envios._data.find(x => x.id == envioId);
|
||||
if (!e) return;
|
||||
|
||||
const cliente = JSON.parse(e.datos_cliente || '{}');
|
||||
const prefill = JSON.parse(e.datos_prefilled || '{}');
|
||||
const todos = { ...prefill, ...cliente };
|
||||
const esquema = JSON.parse(e.esquema || '[]');
|
||||
|
||||
// Mapa id → label desde el esquema
|
||||
const labels = {};
|
||||
esquema.forEach(c => { if (c.id) labels[c.id] = c.label || c.id; });
|
||||
|
||||
let html = `<h6 class="fw-bold mb-1">${esc(e.form_nombre)}</h6>
|
||||
<p class="small text-muted mb-3">
|
||||
Paciente: <strong>${esc(e.paciente_nombre||'—')}</strong>
|
||||
·
|
||||
Completado: ${esc((e.completado_en||'').slice(0,16).replace('T',' '))}
|
||||
</p><hr class="my-2">`;
|
||||
|
||||
// Recorrer en orden del esquema
|
||||
const ordenados = esquema.filter(c =>
|
||||
c.tipo !== 'separador' && c.tipo !== 'firma' && todos[c.id] !== undefined
|
||||
);
|
||||
|
||||
if (ordenados.length) {
|
||||
ordenados.forEach(c => {
|
||||
const v = todos[c.id];
|
||||
const display = Array.isArray(v) ? v.join(', ') : String(v ?? '—');
|
||||
html += `<div class="row mb-2">
|
||||
<div class="col-5 text-muted small">${esc(c.label || c.id)}</div>
|
||||
<div class="col-7 small fw-semibold">${esc(display)}</div>
|
||||
</div>`;
|
||||
});
|
||||
} else {
|
||||
Object.entries(todos).forEach(([k, v]) => {
|
||||
if (k.startsWith('__')) return;
|
||||
const display = Array.isArray(v) ? v.join(', ') : String(v ?? '—');
|
||||
html += `<div class="row mb-2">
|
||||
<div class="col-5 text-muted small">${esc(labels[k] || k)}</div>
|
||||
<div class="col-7 small fw-semibold">${esc(display)}</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
if (e.firma_svg) {
|
||||
html += `<hr class="my-3"><p class="small fw-semibold text-muted">Firma digital:</p>
|
||||
<img src="${e.firma_svg}" class="border rounded p-2"
|
||||
style="max-width:260px;max-height:160px;display:block">`;
|
||||
}
|
||||
|
||||
$('resp-body').innerHTML = html;
|
||||
$('btn-pdf-link').href = `ver_formulario_enviado.php?id=${envioId}`;
|
||||
if (!_bsResp) _bsResp = new bootstrap.Modal('#modalRespuesta');
|
||||
_bsResp.show();
|
||||
},
|
||||
|
||||
imprimir() {
|
||||
window.open(`ver_formulario_enviado.php?id=${this._envioId}`, '_blank');
|
||||
},
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// RENDER CAMPO PARA PREVIEW/PÚBLICO
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
function renderCampoPreview(c, valores) {
|
||||
const v = valores[c.id] || valores[c.linked_key] || '';
|
||||
if (c.tipo === 'separador') {
|
||||
return `<div class="preview-field"><hr class="my-2"><p class="fw-bold small text-secondary mb-0">${esc(c.label)}</p></div>`;
|
||||
}
|
||||
const lbl = `<label class="form-label small fw-semibold mb-1">${esc(c.label)}${c.required?'<span class="text-danger ms-1">*</span>':''}</label>`;
|
||||
if (c.tipo === 'linked') {
|
||||
return `<div class="preview-field">${lbl}
|
||||
<input type="text" class="form-control form-control-sm" value="${esc(v)}" disabled placeholder="Auto-llenado desde paciente">
|
||||
</div>`;
|
||||
}
|
||||
if (c.tipo === 'textarea') {
|
||||
return `<div class="preview-field">${lbl}
|
||||
<textarea class="form-control form-control-sm" rows="2" disabled placeholder="${esc(c.placeholder||'')}"></textarea>
|
||||
</div>`;
|
||||
}
|
||||
if (c.tipo === 'select') {
|
||||
const opts = (c.options||[]).map(o => `<option>${esc(o)}</option>`).join('');
|
||||
return `<div class="preview-field">${lbl}
|
||||
<select class="form-select form-select-sm" disabled><option>— seleccionar —</option>${opts}</select>
|
||||
</div>`;
|
||||
}
|
||||
if (c.tipo === 'radio') {
|
||||
const opts = (c.options||[]).map(o =>
|
||||
`<div class="form-check"><input class="form-check-input" type="radio" disabled>
|
||||
<label class="form-check-label small">${esc(o)}</label></div>`
|
||||
).join('');
|
||||
return `<div class="preview-field">${lbl}${opts}</div>`;
|
||||
}
|
||||
if (c.tipo === 'checkbox') {
|
||||
const opts = (c.options||[]).map(o =>
|
||||
`<div class="form-check"><input class="form-check-input" type="checkbox" disabled>
|
||||
<label class="form-check-label small">${esc(o)}</label></div>`
|
||||
).join('');
|
||||
return `<div class="preview-field">${lbl}${opts}</div>`;
|
||||
}
|
||||
if (c.tipo === 'firma') {
|
||||
return `<div class="preview-field">${lbl}
|
||||
<div class="border rounded p-2 text-center text-muted small bg-light" style="height:70px;line-height:50px">
|
||||
✍️ Área de firma</div></div>`;
|
||||
}
|
||||
if (c.tipo === 'parrafo') {
|
||||
const txt = (c.contenido || '');
|
||||
const ws = c.flujoLibre ? 'normal' : 'pre-wrap';
|
||||
return `<div class="preview-field" style="font-size:11px;line-height:1.7;color:#222;text-align:justify;white-space:${ws};border-top:1px solid #eee;padding-top:6px">${esc(txt.slice(0,200))}${txt.length>200?'…':''}</div>`;
|
||||
}
|
||||
if (c.tipo === 'parrafo_inline') {
|
||||
const renderLine = line => line.split(/(\{[a-z_]+\})/g).map((p, i) => {
|
||||
if (i % 2 === 1) {
|
||||
const key = p.slice(1,-1);
|
||||
return `<span style="display:inline-block;min-width:60px;border-bottom:1px dashed #888;color:#0055aa;font-size:10px;font-style:italic;vertical-align:baseline;padding:0 2px">${esc(key)}</span>`;
|
||||
}
|
||||
return esc(p);
|
||||
}).join('');
|
||||
const html2 = (c.contenido||'').split('\n').map(renderLine).join('<br>');
|
||||
return `<div class="preview-field" style="font-size:11px;line-height:2;color:#222;text-align:justify;border-top:1px solid #eee;padding-top:6px">${html2}</div>`;
|
||||
}
|
||||
if (c.tipo === 'lista_marcable') {
|
||||
const items = (c.items||[]).map((it, i) =>
|
||||
`<div class="form-check"><input class="form-check-input" type="checkbox" disabled>
|
||||
<label class="form-check-label small">${i+1}. ${esc(it)}</label></div>`
|
||||
).join('');
|
||||
return `<div class="preview-field">${lbl}${items}</div>`;
|
||||
}
|
||||
const t = c.tipo === 'numero' ? 'number' : c.tipo === 'fecha' ? 'date' : c.tipo === 'hora' ? 'time' : 'text';
|
||||
return `<div class="preview-field">${lbl}
|
||||
<input type="${t}" class="form-control form-control-sm" value="${esc(v)}"
|
||||
placeholder="${esc(c.placeholder||'')}" disabled>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// HELPERS UI
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
function showToast(msg) {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'alert alert-success position-fixed bottom-0 end-0 m-3 shadow py-2 px-3';
|
||||
t.style.zIndex = '9999';
|
||||
t.textContent = msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(() => t.remove(), 3000);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target.id === 'env-pac-busq' && e.key === 'Enter') envio.buscarPaciente();
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// INIT
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Escuchar mensaje del builder cuando guarda un formulario
|
||||
window.addEventListener('message', e => {
|
||||
if (e.data === 'builder:saved') {
|
||||
cargarFormularios();
|
||||
showToast('✅ Formulario guardado');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Cargar config global primero
|
||||
try {
|
||||
const r = await fetch('api/lab/get_config.php');
|
||||
const d = await r.json();
|
||||
if (d.success) _globalCfg = d.config || {};
|
||||
} catch(e) {}
|
||||
cargarFormularios();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
/**
|
||||
* Gestión de Órdenes Médicas — Módulo Administrativo de Laboratorio
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
$ordenIdParam = (int)($_GET['id'] ?? 0);
|
||||
$estadoParam = $_GET['estado'] ?? '';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Órdenes Médicas — Módulo Lab</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
<style>
|
||||
.orden-row { cursor: pointer; }
|
||||
.flujo-estado { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.flujo-paso { padding: 3px 10px; border-radius: 20px; font-size: .75rem; border: 1px solid #dee2e6; color: #6c757d; }
|
||||
.flujo-paso.activo { background: var(--bs-primary); color: #fff; border-color: var(--bs-primary); font-weight: 600; }
|
||||
.flujo-paso.completado { background: #d1e7dd; color: #0a3622; border-color: #a3cfbb; }
|
||||
.img-orden { max-width: 100%; border-radius: 8px; border: 1px solid #dee2e6; cursor: zoom-in; }
|
||||
.historial-item { font-size: .8rem; padding: 6px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.historial-item:last-child { border-bottom: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_ordenes.php" class="nav-link active"><i class="fas fa-file-medical"></i> Órdenes</a></li>
|
||||
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<li><a href="lab_enfermeras.php" class="nav-link"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
|
||||
<li><a href="lab_formularios.php" class="nav-link"><i class="fas fa-wpforms"></i> Formularios</a></li>
|
||||
<li><a href="lab_reportes.php" class="nav-link"><i class="fas fa-chart-bar"></i> Reportes</a></li>
|
||||
<li><a href="lab_configuracion.php" class="nav-link"><i class="fas fa-sliders-h"></i> Configuración</a></li>
|
||||
<li><a href="lab_usuarios.php" class="nav-link"><i class="fas fa-users-cog"></i> Usuarios</a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
|
||||
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="content-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h1><i class="fas fa-file-medical text-primary"></i> Órdenes Médicas</h1>
|
||||
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<!-- Filtros + contadores -->
|
||||
<div class="row g-2 mb-3" id="contadores-row">
|
||||
<?php foreach(['pendiente'=>['warning','clock'],'en_revision'=>['info','magnifying-glass'],'autorizada'=>['success','check-circle'],'rechazada'=>['danger','times-circle'],'en_domicilio'=>['primary','house-medical'],'completada'=>['secondary','flag-checkered']] as $e=>[$color,$icon]): ?>
|
||||
<div class="col-6 col-md-2">
|
||||
<button class="btn btn-outline-<?=$color?> btn-sm w-100 filtro-estado <?= $estadoParam===$e?'active':'' ?>"
|
||||
data-estado="<?=$e?>" onclick="filtrarEstado('<?=$e?>')">
|
||||
<i class="fas fa-<?=$icon?> me-1"></i> <?=ucfirst(str_replace('_',' ',$e))?><br>
|
||||
<strong id="cnt-<?=$e?>">—</strong>
|
||||
</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Barra de búsqueda -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-4">
|
||||
<input type="text" id="buscador" class="form-control form-control-sm" placeholder="Buscar paciente…" oninput="debounce(cargarLista,400)()">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="date" id="filtro-desde" class="form-control form-control-sm" onchange="cargarLista()">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="date" id="filtro-hasta" class="form-control form-control-sm" onchange="cargarLista()">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-sm btn-secondary w-100" onclick="limpiarFiltros()">
|
||||
<i class="fas fa-eraser me-1"></i> Limpiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Tabla de órdenes -->
|
||||
<div class="col-lg-5">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>#</th><th>Paciente</th><th>Fecha</th><th>Estado</th></tr>
|
||||
</thead>
|
||||
<tbody id="tabla-body">
|
||||
<tr><td colspan="4" class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between px-3 py-2 border-top" id="paginacion"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel de detalle de orden -->
|
||||
<div class="col-lg-7" id="detail-panel">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0 pb-0">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0 fw-semibold">Detalle de Orden <span id="detail-id" class="text-muted"></span></h6>
|
||||
<button class="btn-close" onclick="cerrarDetalle()"></button>
|
||||
</div>
|
||||
<!-- Flujo de estados -->
|
||||
<div class="flujo-estado mt-2" id="flujo-row"></div>
|
||||
</div>
|
||||
<div class="card-body" id="detail-body">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-arrow-left me-2"></i>Selecciona una orden de la lista
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal Autorizar / Rechazar -->
|
||||
<div class="modal fade" id="modalAccion" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-accion-titulo">Acción</h5>
|
||||
<button class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="accion-id">
|
||||
<input type="hidden" id="accion-tipo">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" id="accion-label">Comentario</label>
|
||||
<textarea class="form-control" id="accion-comentario" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button class="btn btn-primary" id="btn-confirmar-accion" onclick="ejecutarAccion()">Confirmar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal visor de imagen -->
|
||||
<div class="modal fade" id="modalImagen" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content bg-dark">
|
||||
<div class="modal-header border-0 py-2">
|
||||
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center p-0">
|
||||
<img id="img-visor" src="" class="img-fluid" style="max-height:80vh;object-fit:contain">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
let paginaActual = 1;
|
||||
let filtroEstado = '<?= htmlspecialchars($estadoParam) ?>';
|
||||
let ordenSeleccionada = <?= $ordenIdParam ?: 'null' ?>;
|
||||
|
||||
const modalAccion = new bootstrap.Modal('#modalAccion');
|
||||
const modalImg = new bootstrap.Modal('#modalImagen');
|
||||
|
||||
const FLUJO = ['pendiente','en_revision','autorizada','rechazada','en_domicilio','completada'];
|
||||
const COLOR = { pendiente:'warning', en_revision:'info', autorizada:'success', rechazada:'danger', en_domicilio:'primary', completada:'secondary' };
|
||||
|
||||
// ── Lista ──────────────────────────────────────────────────────────────────
|
||||
async function cargarLista(pag = 1) {
|
||||
paginaActual = pag;
|
||||
const params = new URLSearchParams({
|
||||
page: pag, limit: 20,
|
||||
busqueda: document.getElementById('buscador').value.trim(),
|
||||
desde: document.getElementById('filtro-desde').value,
|
||||
hasta: document.getElementById('filtro-hasta').value,
|
||||
estado: filtroEstado,
|
||||
});
|
||||
|
||||
const r = await fetch(`api/lab/get_ordenes.php?${params}`);
|
||||
const d = await r.json();
|
||||
|
||||
// Actualizar contadores
|
||||
if (d.contadores) {
|
||||
Object.entries(d.contadores).forEach(([e, n]) => {
|
||||
const el = document.getElementById(`cnt-${e}`);
|
||||
if (el) el.textContent = n;
|
||||
});
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('tabla-body');
|
||||
if (!d.data?.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Sin resultados</td></tr>';
|
||||
document.getElementById('paginacion').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = d.data.map(o => `
|
||||
<tr class="orden-row ${ordenSeleccionada===o.id?'table-active':''}"
|
||||
onclick="verOrden(${o.id})" id="row-${o.id}">
|
||||
<td class="text-muted">#${o.id}</td>
|
||||
<td>
|
||||
<div class="fw-semibold">${esc(o.paciente_nombre)}</div>
|
||||
<small class="text-muted">${esc(o.numero_documento||'')}</small>
|
||||
</td>
|
||||
<td>${formatFecha(o.created_at)}</td>
|
||||
<td><span class="badge bg-${COLOR[o.estado]||'secondary'}">${esc(o.estado)}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
renderPaginacion(d, pag);
|
||||
if (ordenSeleccionada) verOrden(ordenSeleccionada);
|
||||
}
|
||||
|
||||
function renderPaginacion(d, pag) {
|
||||
const el = document.getElementById('paginacion');
|
||||
if (d.paginas <= 1) { el.innerHTML = `<small class="text-muted">${d.total} orden(es)</small>`; return; }
|
||||
el.innerHTML =
|
||||
`<small class="text-muted">${((pag-1)*20)+1}–${Math.min(pag*20,d.total)} de ${d.total}</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
${pag>1?`<button class="btn btn-outline-secondary" onclick="cargarLista(${pag-1})"><i class="fas fa-chevron-left"></i></button>`:''}
|
||||
<button class="btn btn-secondary disabled">${pag}/${d.paginas}</button>
|
||||
${pag<d.paginas?`<button class="btn btn-outline-secondary" onclick="cargarLista(${pag+1})"><i class="fas fa-chevron-right"></i></button>`:''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Detalle ────────────────────────────────────────────────────────────────
|
||||
async function verOrden(id) {
|
||||
ordenSeleccionada = id;
|
||||
document.querySelectorAll('.orden-row').forEach(r => r.classList.remove('table-active'));
|
||||
const rowEl = document.getElementById(`row-${id}`);
|
||||
if (rowEl) rowEl.classList.add('table-active');
|
||||
|
||||
document.getElementById('detail-body').innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
document.getElementById('detail-id').textContent = `#${id}`;
|
||||
|
||||
const r = await fetch(`api/lab/get_ordenes.php?id=${id}&no_counts=1`);
|
||||
const d = await r.json();
|
||||
const o = d.orden;
|
||||
if (!o) return;
|
||||
|
||||
// Flujo
|
||||
const idx = FLUJO.indexOf(o.estado);
|
||||
document.getElementById('flujo-row').innerHTML = FLUJO.map((e, i) => {
|
||||
let cls = 'flujo-paso';
|
||||
if (i < idx) cls += ' completado';
|
||||
if (e === o.estado) cls += ' activo';
|
||||
return `<span class="${cls}">${esc(e.replace('_',' '))}</span>${i < FLUJO.length-1 ? '<i class="fas fa-chevron-right text-muted" style="font-size:.65rem"></i>' : ''}`;
|
||||
}).join('');
|
||||
|
||||
// Imagen de la orden
|
||||
const imgSrc = o.local_file
|
||||
? `uploads/media/${esc(o.local_file)}`
|
||||
: (o.conv_local_file ? `uploads/media/${esc(o.conv_local_file)}` : null);
|
||||
|
||||
// Botones de acción según estado
|
||||
const botonesAccion = renderBotones(o);
|
||||
|
||||
document.getElementById('detail-body').innerHTML = `
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
${imgSrc ? `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted text-uppercase">Imagen de la orden</label>
|
||||
<img src="${imgSrc}" class="img-orden" onclick="verImagen('${imgSrc}')" title="Ver imagen completa">
|
||||
</div>` : '<div class="alert alert-light small text-muted"><i class="fas fa-image me-2"></i>Sin imagen adjunta</div>'}
|
||||
|
||||
<dl class="row small">
|
||||
<dt class="col-5 text-muted">Paciente</dt>
|
||||
<dd class="col-7 fw-semibold"><a href="lab_pacientes.php" onclick="event.preventDefault()">${esc(o.paciente_nombre)}</a></dd>
|
||||
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${esc(o.numero_documento||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(o.paciente_telefono||'—')}</dd>
|
||||
<dt class="col-5 text-muted">EPS</dt><dd class="col-7">${esc(o.paciente_eps||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Médico</dt><dd class="col-7">${esc(o.medico_nombre||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Reg. médico</dt><dd class="col-7">${esc(o.medico_registro||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Fecha orden</dt><dd class="col-7">${esc(o.fecha_orden||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Ayuno</dt>
|
||||
<dd class="col-7">${o.requiere_ayuno ? `<span class="badge bg-warning text-dark">${o.horas_ayuno||'?'}h ayuno</span>` : 'No requiere'}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
${o.examenes_solicitados ? `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted text-uppercase">Exámenes solicitados</label>
|
||||
<div class="bg-light p-2 rounded small">${esc(o.examenes_solicitados).replace(/\n/g,'<br>')}</div>
|
||||
</div>` : ''}
|
||||
|
||||
${o.indicaciones ? `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted text-uppercase">Indicaciones</label>
|
||||
<div class="bg-light p-2 rounded small">${esc(o.indicaciones).replace(/\n/g,'<br>')}</div>
|
||||
</div>` : ''}
|
||||
|
||||
${o.comentario_revision ? `
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted text-uppercase">Comentario revisión</label>
|
||||
<div class="alert alert-${o.estado==='rechazada'?'danger':'success'} py-2 small mb-0">${esc(o.comentario_revision)}</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Botones de acción -->
|
||||
<div class="d-flex gap-2 flex-wrap mb-3">${botonesAccion}</div>
|
||||
|
||||
<!-- Historial -->
|
||||
<label class="form-label small text-muted text-uppercase">Historial de cambios</label>
|
||||
<div class="border rounded p-2">
|
||||
${(o.historial||[]).map(h => `
|
||||
<div class="historial-item">
|
||||
<span class="fw-semibold">${esc(h.admin_nombre||'Sistema')}</span>
|
||||
— <span class="badge bg-${COLOR[h.accion]||'secondary'} text-uppercase">${esc(h.accion)}</span>
|
||||
${h.comentario ? ` — <em class="text-muted">${esc(h.comentario)}</em>` : ''}
|
||||
<br><span class="text-muted">${formatFecha(h.created_at)}</span>
|
||||
</div>`).join('') || '<p class="text-muted small mb-0">Sin historial</p>'}
|
||||
</div>
|
||||
|
||||
${o.estado === 'autorizada' ? `
|
||||
<div class="mt-3">
|
||||
<a href="lab_domicilios.php?orden_id=${o.id}&paciente_id=${o.paciente_id}" class="btn btn-success w-100 btn-sm">
|
||||
<i class="fas fa-house-medical me-1"></i> Crear domicilio para esta orden
|
||||
</a>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBotones(o) {
|
||||
const btns = [];
|
||||
if (o.estado === 'pendiente')
|
||||
btns.push(`<button class="btn btn-sm btn-info" onclick="accion(${o.id},'en_revision')"><i class="fas fa-magnifying-glass me-1"></i>Iniciar revisión</button>`);
|
||||
if (o.estado === 'en_revision') {
|
||||
btns.push(`<button class="btn btn-sm btn-success" onclick="accion(${o.id},'autorizada')"><i class="fas fa-check me-1"></i>Autorizar</button>`);
|
||||
btns.push(`<button class="btn btn-sm btn-danger" onclick="accion(${o.id},'rechazada')"><i class="fas fa-times me-1"></i>Rechazar</button>`);
|
||||
}
|
||||
if (o.estado === 'en_domicilio')
|
||||
btns.push(`<button class="btn btn-sm btn-secondary" onclick="accion(${o.id},'completada')"><i class="fas fa-flag-checkered me-1"></i>Completar</button>`);
|
||||
return btns.join('');
|
||||
}
|
||||
|
||||
function cerrarDetalle() {
|
||||
ordenSeleccionada = null;
|
||||
document.querySelectorAll('.orden-row').forEach(r => r.classList.remove('table-active'));
|
||||
document.getElementById('detail-body').innerHTML = '<div class="text-center py-5 text-muted"><i class="fas fa-arrow-left me-2"></i>Selecciona una orden</div>';
|
||||
document.getElementById('flujo-row').innerHTML = '';
|
||||
document.getElementById('detail-id').textContent = '';
|
||||
}
|
||||
|
||||
// ── Acciones ───────────────────────────────────────────────────────────────
|
||||
function accion(id, tipo) {
|
||||
document.getElementById('accion-id').value = id;
|
||||
document.getElementById('accion-tipo').value = tipo;
|
||||
document.getElementById('accion-comentario').value = '';
|
||||
const titulos = {
|
||||
en_revision: 'Iniciar revisión',
|
||||
autorizada: 'Autorizar orden',
|
||||
rechazada: 'Rechazar orden',
|
||||
completada: 'Completar orden',
|
||||
};
|
||||
document.getElementById('modal-accion-titulo').textContent = titulos[tipo] || 'Confirmar acción';
|
||||
document.getElementById('accion-label').textContent = tipo==='rechazada' ? 'Motivo del rechazo *' : 'Comentario (opcional)';
|
||||
document.getElementById('accion-comentario').required = tipo === 'rechazada';
|
||||
document.getElementById('btn-confirmar-accion').className = `btn btn-${tipo==='rechazada'?'danger':tipo==='autorizada'?'success':'primary'}`;
|
||||
modalAccion.show();
|
||||
}
|
||||
|
||||
async function ejecutarAccion() {
|
||||
const id = parseInt(document.getElementById('accion-id').value);
|
||||
const tipo = document.getElementById('accion-tipo').value;
|
||||
const com = document.getElementById('accion-comentario').value.trim();
|
||||
|
||||
if (tipo === 'rechazada' && !com) {
|
||||
document.getElementById('accion-comentario').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await fetch('api/lab/autorizar_orden.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ id, accion: tipo, comentario: com }),
|
||||
});
|
||||
const d = await r.json();
|
||||
|
||||
modalAccion.hide();
|
||||
if (d.success) {
|
||||
mostrarToast(d.message, 'success');
|
||||
await cargarLista(paginaActual);
|
||||
if (ordenSeleccionada) verOrden(ordenSeleccionada);
|
||||
} else {
|
||||
mostrarToast(d.error || 'Error', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function filtrarEstado(e) {
|
||||
filtroEstado = (filtroEstado === e) ? '' : e;
|
||||
document.querySelectorAll('.filtro-estado').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.estado === filtroEstado);
|
||||
});
|
||||
cargarLista(1);
|
||||
}
|
||||
|
||||
function limpiarFiltros() {
|
||||
filtroEstado = '';
|
||||
document.getElementById('buscador').value = '';
|
||||
document.getElementById('filtro-desde').value = '';
|
||||
document.getElementById('filtro-hasta').value = '';
|
||||
document.querySelectorAll('.filtro-estado').forEach(b => b.classList.remove('active'));
|
||||
cargarLista(1);
|
||||
}
|
||||
|
||||
function verImagen(src) {
|
||||
document.getElementById('img-visor').src = src;
|
||||
modalImg.show();
|
||||
}
|
||||
|
||||
// ── Utils ──────────────────────────────────────────────────────────────────
|
||||
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||
const formatFecha = s => s ? new Date(s).toLocaleString('es-CO', {day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}) : '—';
|
||||
function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(()=>fn(...a),ms); }; }
|
||||
function mostrarToast(msg, tipo='success') {
|
||||
const div = document.createElement('div');
|
||||
div.className = `alert alert-${tipo} alert-dismissible position-fixed bottom-0 end-0 m-3`;
|
||||
div.style.zIndex = 9999;
|
||||
div.innerHTML = `${esc(msg)}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||||
document.body.appendChild(div);
|
||||
setTimeout(()=>div.remove(), 4000);
|
||||
}
|
||||
|
||||
cargarLista();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
/**
|
||||
* Gestión de Pacientes — Módulo Administrativo de Laboratorio
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pacientes — Módulo Lab</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
<style>
|
||||
.table-pacientes th { font-size:.8rem; text-transform:uppercase; letter-spacing:.04em; white-space:nowrap; }
|
||||
.hist-badge { font-size:.72rem; }
|
||||
#detail-panel { display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_ordenes.php" class="nav-link"><i class="fas fa-file-medical"></i> Órdenes</a></li>
|
||||
<li><a href="lab_pacientes.php" class="nav-link active"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<li><a href="lab_enfermeras.php" class="nav-link"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
|
||||
<li><a href="lab_formularios.php" class="nav-link"><i class="fas fa-wpforms"></i> Formularios</a></li>
|
||||
<li><a href="lab_reportes.php" class="nav-link"><i class="fas fa-chart-bar"></i> Reportes</a></li>
|
||||
<li><a href="lab_configuracion.php" class="nav-link"><i class="fas fa-sliders-h"></i> Configuración</a></li>
|
||||
<li><a href="lab_usuarios.php" class="nav-link"><i class="fas fa-users-cog"></i> Usuarios</a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
|
||||
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="content-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h1><i class="fas fa-users text-primary"></i> Pacientes</h1>
|
||||
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="abrirFormulario()">
|
||||
<i class="fas fa-plus me-1"></i> Nuevo Paciente
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<!-- Buscador -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-5">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||
<input type="text" id="buscador" class="form-control" placeholder="Nombre, documento o teléfono…" oninput="debounce(cargarLista, 400)()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Lista -->
|
||||
<div class="col-lg-7" id="lista-col">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-pacientes mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Paciente</th>
|
||||
<th>Documento</th>
|
||||
<th>Teléfono</th>
|
||||
<th>EPS</th>
|
||||
<th>Órdenes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tabla-body">
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Paginación -->
|
||||
<div class="d-flex align-items-center justify-content-between px-3 py-2 border-top" id="paginacion"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel de detalle -->
|
||||
<div class="col-lg-5" id="detail-panel">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0 d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0 fw-semibold" id="detail-nombre">Paciente</h6>
|
||||
<button class="btn-close" onclick="cerrarDetalle()"></button>
|
||||
</div>
|
||||
<div class="card-body" id="detail-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /container -->
|
||||
</main>
|
||||
|
||||
<!-- Modal Formulario Paciente -->
|
||||
<div class="modal fade" id="modalPaciente" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-titulo"><i class="fas fa-user me-2"></i>Nuevo Paciente</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="form-paciente">
|
||||
<input type="hidden" name="id" id="pac-id">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Nombre completo <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="nombre_completo" id="pac-nombre" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Tipo doc.</label>
|
||||
<select class="form-select" name="tipo_documento" id="pac-tipo-doc">
|
||||
<option value="CC">CC</option><option value="CE">CE</option>
|
||||
<option value="TI">TI</option><option value="PA">PA</option>
|
||||
<option value="NIT">NIT</option><option value="RC">RC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Número de documento</label>
|
||||
<input type="text" class="form-control" name="numero_documento" id="pac-doc">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Teléfono</label>
|
||||
<input type="tel" class="form-control" name="telefono" id="pac-tel">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" name="email" id="pac-email">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Fecha nacimiento</label>
|
||||
<input type="date" class="form-control" name="fecha_nacimiento" id="pac-fnac">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Género</label>
|
||||
<select class="form-select" name="genero" id="pac-genero">
|
||||
<option value="">—</option>
|
||||
<option value="M">Masculino</option>
|
||||
<option value="F">Femenino</option>
|
||||
<option value="O">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">EPS</label>
|
||||
<input type="text" class="form-control" name="eps" id="pac-eps">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Ciudad</label>
|
||||
<input type="text" class="form-control" name="ciudad" id="pac-ciudad">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Barrio</label>
|
||||
<input type="text" class="form-control" name="barrio" id="pac-barrio">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Dirección</label>
|
||||
<input type="text" class="form-control" name="direccion" id="pac-dir">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notas internas</label>
|
||||
<textarea class="form-control" name="notas_admin" id="pac-notas" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary" onclick="guardarPaciente()">
|
||||
<i class="fas fa-save me-1"></i> Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
let paginaActual = 1;
|
||||
const modal = new bootstrap.Modal('#modalPaciente');
|
||||
|
||||
// ── Lista ──────────────────────────────────────────────────────────────────
|
||||
async function cargarLista(pag = 1) {
|
||||
paginaActual = pag;
|
||||
const busq = document.getElementById('buscador').value.trim();
|
||||
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="6" class="text-center py-4 text-muted">Sin resultados</td></tr>';
|
||||
document.getElementById('paginacion').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = d.data.map(p => `
|
||||
<tr style="cursor:pointer" onclick="verDetalle(${p.id})">
|
||||
<td>
|
||||
<div class="fw-semibold">${esc(p.nombre_completo)}</div>
|
||||
${p.phone_number ? `<small class="text-muted"><i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}</small>` : ''}
|
||||
</td>
|
||||
<td>${esc(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td>
|
||||
<td>${esc(p.telefono||'—')}</td>
|
||||
<td>${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" onclick="event.stopPropagation();editarPaciente(${p.id})" title="Editar">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Paginación
|
||||
const pag_html = [];
|
||||
if (d.paginas > 1) {
|
||||
pag_html.push(`<small class="text-muted">Mostrando ${((pag-1)*25)+1}–${Math.min(pag*25,d.total)} de ${d.total}</small>`);
|
||||
pag_html.push(`<div class="btn-group btn-group-sm">`);
|
||||
if (pag > 1) pag_html.push(`<button class="btn btn-outline-secondary" onclick="cargarLista(${pag-1})"><i class="fas fa-chevron-left"></i></button>`);
|
||||
pag_html.push(`<button class="btn btn-secondary disabled">${pag}/${d.paginas}</button>`);
|
||||
if (pag < d.paginas) pag_html.push(`<button class="btn btn-outline-secondary" onclick="cargarLista(${pag+1})"><i class="fas fa-chevron-right"></i></button>`);
|
||||
pag_html.push(`</div>`);
|
||||
} else {
|
||||
pag_html.push(`<small class="text-muted">${d.total} paciente(s)</small>`);
|
||||
}
|
||||
document.getElementById('paginacion').innerHTML = pag_html.join('');
|
||||
}
|
||||
|
||||
// ── Detalle ────────────────────────────────────────────────────────────────
|
||||
async function verDetalle(id) {
|
||||
document.getElementById('detail-panel').style.display = 'block';
|
||||
document.getElementById('detail-body').innerHTML = '<div class="text-center py-3"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
|
||||
const r = await fetch(`api/lab/get_pacientes.php?id=${id}`);
|
||||
const d = await r.json();
|
||||
const p = d.data?.[0];
|
||||
if (!p) return;
|
||||
|
||||
document.getElementById('detail-nombre').textContent = p.nombre_completo;
|
||||
|
||||
const r2 = await fetch(`api/lab/get_ordenes.php?paciente_id=${id}&limit=10&no_counts=1`);
|
||||
const od = await r2.json();
|
||||
const ords = od.data || [];
|
||||
|
||||
document.getElementById('detail-body').innerHTML = `
|
||||
<dl class="row small mb-3">
|
||||
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${esc(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
|
||||
<dt class="col-5 text-muted">EPS</dt><dd class="col-7">${esc(p.eps||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Ciudad</dt><dd class="col-7">${esc(p.ciudad||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Dirección</dt><dd class="col-7">${esc(p.direccion||'—')}</dd>
|
||||
</dl>
|
||||
<hr>
|
||||
<h6 class="fw-semibold mb-2"><i class="fas fa-file-medical me-2 text-primary"></i>Órdenes médicas (${ords.length})</h6>
|
||||
${ords.length ? `
|
||||
<table class="table table-sm table-hover">
|
||||
<thead class="table-light"><tr><th>#</th><th>Fecha</th><th>Estado</th></tr></thead>
|
||||
<tbody>
|
||||
${ords.map(o => `<tr onclick="location.href='lab_ordenes.php?id=${o.id}'" style="cursor:pointer">
|
||||
<td>#${o.id}</td>
|
||||
<td>${formatFecha(o.created_at)}</td>
|
||||
<td><span class="badge bg-${colorEstado(o.estado)}">${esc(o.estado)}</span></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>` : '<p class="text-muted small">Sin órdenes</p>'}
|
||||
<button class="btn btn-sm btn-outline-primary w-100 mt-2" onclick="editarPaciente(${p.id})">
|
||||
<i class="fas fa-pen me-1"></i> Editar datos
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function cerrarDetalle() {
|
||||
document.getElementById('detail-panel').style.display = 'none';
|
||||
}
|
||||
|
||||
// ── Formulario ─────────────────────────────────────────────────────────────
|
||||
function abrirFormulario(p = null) {
|
||||
const form = document.getElementById('form-paciente');
|
||||
form.reset();
|
||||
document.getElementById('pac-id').value = p ? p.id : '';
|
||||
document.getElementById('modal-titulo').innerHTML = p
|
||||
? `<i class="fas fa-pen me-2"></i>Editar Paciente`
|
||||
: `<i class="fas fa-plus me-2"></i>Nuevo Paciente`;
|
||||
|
||||
if (p) {
|
||||
document.getElementById('pac-nombre').value = p.nombre_completo || '';
|
||||
document.getElementById('pac-doc').value = p.numero_documento || '';
|
||||
document.getElementById('pac-tipo-doc').value= p.tipo_documento || 'CC';
|
||||
document.getElementById('pac-tel').value = p.telefono || '';
|
||||
document.getElementById('pac-email').value = p.email || '';
|
||||
document.getElementById('pac-fnac').value = p.fecha_nacimiento || '';
|
||||
document.getElementById('pac-genero').value = p.genero || '';
|
||||
document.getElementById('pac-eps').value = p.eps || '';
|
||||
document.getElementById('pac-ciudad').value = p.ciudad || '';
|
||||
document.getElementById('pac-barrio').value = p.barrio || '';
|
||||
document.getElementById('pac-dir').value = p.direccion || '';
|
||||
document.getElementById('pac-notas').value = p.notas_admin || '';
|
||||
}
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async function editarPaciente(id) {
|
||||
const r = await fetch(`api/lab/get_pacientes.php?id=${id}`);
|
||||
const d = await r.json();
|
||||
if (d.data?.[0]) abrirFormulario(d.data[0]);
|
||||
}
|
||||
|
||||
async function guardarPaciente() {
|
||||
const form = document.getElementById('form-paciente');
|
||||
if (!form.checkValidity()) { form.reportValidity(); return; }
|
||||
|
||||
const datos = Object.fromEntries(new FormData(form).entries());
|
||||
if (!datos.id) delete datos.id;
|
||||
|
||||
const r = await fetch('api/lab/save_paciente.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(datos),
|
||||
});
|
||||
const d = await r.json();
|
||||
|
||||
if (d.success) {
|
||||
modal.hide();
|
||||
mostrarToast(d.message, 'success');
|
||||
cargarLista(paginaActual);
|
||||
} else {
|
||||
mostrarToast(d.error || 'Error al guardar', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utils ──────────────────────────────────────────────────────────────────
|
||||
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||
const formatFecha = s => s ? new Date(s).toLocaleDateString('es-CO') : '—';
|
||||
const colorEstado = e => ({
|
||||
pendiente:'warning', en_revision:'info', autorizada:'success',
|
||||
rechazada:'danger', en_domicilio:'primary', completada:'secondary',
|
||||
}[e] || 'secondary');
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
function mostrarToast(msg, tipo = 'success') {
|
||||
const div = document.createElement('div');
|
||||
div.className = `alert alert-${tipo} alert-dismissible position-fixed bottom-0 end-0 m-3`;
|
||||
div.style.zIndex = 9999;
|
||||
div.innerHTML = `${esc(msg)}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||||
document.body.appendChild(div);
|
||||
setTimeout(() => div.remove(), 4000);
|
||||
}
|
||||
|
||||
// Init
|
||||
cargarLista();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
/**
|
||||
* Reportes y Trazabilidad — Módulo Administrativo de Laboratorio
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||||
|
||||
// ── Exportación CSV directa (sin cabeceras HTML) ──────────────────────────
|
||||
$tipo = $_GET['export'] ?? '';
|
||||
if ($tipo) {
|
||||
require_once 'classes/Database.php';
|
||||
$db = Database::getInstance();
|
||||
|
||||
$desde = $_GET['desde'] ?? date('Y-m-01');
|
||||
$hasta = $_GET['hasta'] ?? date('Y-m-d');
|
||||
|
||||
switch ($tipo) {
|
||||
case 'ordenes':
|
||||
$rows = $db->fetchAll(
|
||||
"SELECT o.id, p.nombre_completo AS paciente, p.numero_documento, p.eps,
|
||||
o.estado, o.medico_nombre, o.fecha_orden, o.examenes_solicitados,
|
||||
o.requiere_ayuno, o.horas_ayuno, o.created_at,
|
||||
ar.full_name AS revisada_por, aa.full_name AS autorizada_por
|
||||
FROM lab_ordenes_medicas o
|
||||
JOIN lab_pacientes p ON p.id = o.paciente_id
|
||||
LEFT JOIN admin_users ar ON ar.id = o.revisada_por
|
||||
LEFT JOIN admin_users aa ON aa.id = o.autorizada_por
|
||||
WHERE DATE(o.created_at) BETWEEN ? AND ?
|
||||
ORDER BY o.created_at DESC",
|
||||
[$desde, $hasta]
|
||||
);
|
||||
$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;
|
||||
|
||||
case 'domicilios':
|
||||
$rows = $db->fetchAll(
|
||||
"SELECT d.id, p.nombre_completo AS paciente, p.telefono AS tel_paciente,
|
||||
d.direccion, d.barrio, d.ciudad, d.fecha_programada, d.hora_programada,
|
||||
d.estado, COALESCE(e.nombre_completo,'Sin asignar') AS enfermera,
|
||||
d.tipo_servicio, d.created_at
|
||||
FROM lab_domicilios d
|
||||
JOIN lab_pacientes p ON p.id = d.paciente_id
|
||||
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id AND a.estado NOT IN ('liberada')
|
||||
LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id
|
||||
WHERE DATE(d.fecha_programada) BETWEEN ? AND ?
|
||||
ORDER BY d.fecha_programada, d.hora_programada",
|
||||
[$desde, $hasta]
|
||||
);
|
||||
$filename = "domicilios_$desde\_$hasta.csv";
|
||||
$headers = ['ID','Paciente','Teléfono','Dirección','Barrio','Ciudad','Fecha','Hora','Estado','Enfermera','Tipo Servicio','Registrado'];
|
||||
break;
|
||||
|
||||
case 'pacientes':
|
||||
$rows = $db->fetchAll(
|
||||
"SELECT p.id, p.nombre_completo, p.tipo_documento, p.numero_documento,
|
||||
p.telefono, p.email, p.eps, p.ciudad, p.barrio, p.is_active,
|
||||
COUNT(o.id) AS total_ordenes, p.created_at
|
||||
FROM lab_pacientes p
|
||||
LEFT JOIN lab_ordenes_medicas o ON o.paciente_id = p.id
|
||||
GROUP BY p.id
|
||||
ORDER BY p.nombre_completo",
|
||||
[]
|
||||
);
|
||||
$filename = "pacientes_" . date('Y-m-d') . ".csv";
|
||||
$headers = ['ID','Nombre','Tipo Doc.','Documento','Teléfono','Email','EPS','Ciudad','Barrio','Activo','Total Órdenes','Registrado'];
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
exit('Tipo de exportación no válido');
|
||||
}
|
||||
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header("Content-Disposition: attachment; filename=\"$filename\"");
|
||||
header('Pragma: no-cache');
|
||||
|
||||
$f = fopen('php://output', 'w');
|
||||
fputs($f, "\xEF\xBB\xBF"); // BOM UTF-8 para Excel
|
||||
fputcsv($f, $headers);
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($f, array_values($row));
|
||||
}
|
||||
fclose($f);
|
||||
exit;
|
||||
}
|
||||
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reportes — Módulo Lab</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
<style>
|
||||
.log-item { font-size:.82rem; padding:6px 0; border-bottom:1px solid #f3f3f3; }
|
||||
.log-item:last-child { border-bottom:none; }
|
||||
.badge-modulo { font-size:.7rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_ordenes.php" class="nav-link"><i class="fas fa-file-medical"></i> Órdenes</a></li>
|
||||
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<li><a href="lab_enfermeras.php" class="nav-link"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
|
||||
<li><a href="lab_formularios.php" class="nav-link"><i class="fas fa-wpforms"></i> Formularios</a></li>
|
||||
<li><a href="lab_reportes.php" class="nav-link active"><i class="fas fa-chart-bar"></i> Reportes</a></li>
|
||||
<li><a href="lab_configuracion.php" class="nav-link"><i class="fas fa-sliders-h"></i> Configuración</a></li>
|
||||
<li><a href="lab_usuarios.php" class="nav-link"><i class="fas fa-users-cog"></i> Usuarios</a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
|
||||
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="content-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h1><i class="fas fa-chart-bar text-primary"></i> Reportes y Trazabilidad</h1>
|
||||
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<!-- ── Rango de fechas ──────────────────────────────────────────── -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body d-flex flex-wrap gap-3 align-items-end">
|
||||
<div>
|
||||
<label class="form-label small text-muted text-uppercase">Desde</label>
|
||||
<input type="date" id="rpt-desde" class="form-control form-control-sm" value="<?= date('Y-m-01') ?>">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label small text-muted text-uppercase">Hasta</label>
|
||||
<input type="date" id="rpt-hasta" class="form-control form-control-sm" value="<?= date('Y-m-d') ?>">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="cargarTodo()">
|
||||
<i class="fas fa-sync-alt me-1"></i> Actualizar
|
||||
</button>
|
||||
<div class="ms-auto d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-success" onclick="exportar('ordenes')">
|
||||
<i class="fas fa-file-csv me-1"></i> Exportar Órdenes
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="exportar('domicilios')">
|
||||
<i class="fas fa-file-csv me-1"></i> Exportar Domicilios
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="exportar('pacientes')">
|
||||
<i class="fas fa-file-csv me-1"></i> Exportar Pacientes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- ── Resumen estadístico ──────────────────────────────────── -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h6 class="fw-semibold mb-0"><i class="fas fa-chart-pie me-2 text-primary"></i>Resumen del período</h6>
|
||||
</div>
|
||||
<div class="card-body" id="resumen-stats">
|
||||
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Órdenes por estado -->
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h6 class="fw-semibold mb-0"><i class="fas fa-file-medical me-2 text-warning"></i>Órdenes por estado</h6>
|
||||
</div>
|
||||
<div class="card-body" id="ordenes-estado-chart">
|
||||
<div class="text-center py-3 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domicilios por estado -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h6 class="fw-semibold mb-0"><i class="fas fa-house-medical me-2 text-success"></i>Domicilios por estado</h6>
|
||||
</div>
|
||||
<div class="card-body" id="domicilios-estado-chart">
|
||||
<div class="text-center py-3 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Log de actividad ─────────────────────────────────────── -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white border-0 d-flex align-items-center justify-content-between">
|
||||
<h6 class="fw-semibold mb-0"><i class="fas fa-history me-2 text-info"></i>Log de actividad</h6>
|
||||
<div>
|
||||
<select id="log-modulo" class="form-select form-select-sm" onchange="cargarActividad(1)">
|
||||
<option value="">Todos los módulos</option>
|
||||
<option value="ordenes">Órdenes</option>
|
||||
<option value="domicilios">Domicilios</option>
|
||||
<option value="pacientes">Pacientes</option>
|
||||
<option value="enfermeras">Enfermeras</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pb-0" id="log-body" style="max-height:520px;overflow-y:auto">
|
||||
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
|
||||
</div>
|
||||
<div class="card-footer bg-white border-0" id="log-paginacion"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /container -->
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const COLOR = { pendiente:'warning', en_revision:'info', autorizada:'success', rechazada:'danger', en_domicilio:'primary', completada:'secondary', programado:'secondary', confirmado:'info', en_camino:'primary', cancelado:'danger' };
|
||||
const MODULO_COLOR = { ordenes:'warning', domicilios:'success', pacientes:'primary', enfermeras:'info', asignaciones:'secondary' };
|
||||
let logPag = 1;
|
||||
|
||||
async function cargarTodo() {
|
||||
await Promise.all([cargarResumen(), cargarActividad(1)]);
|
||||
}
|
||||
|
||||
async function cargarResumen() {
|
||||
const r = await fetch(`api/lab/get_stats.php?desde=${fecha('rpt-desde')}&hasta=${fecha('rpt-hasta')}`);
|
||||
const d = await r.json();
|
||||
if (!d.success) return;
|
||||
|
||||
// Resumen general
|
||||
const g = d.generales || {};
|
||||
document.getElementById('resumen-stats').innerHTML = `
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
<div class="fs-4 fw-bold text-warning">${d.ordenes?.total_periodo||g.total_ordenes||0}</div>
|
||||
<div class="small text-muted">Órdenes</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
<div class="fs-4 fw-bold text-success">${d.ordenes?.autorizadas_periodo||0}</div>
|
||||
<div class="small text-muted">Autorizadas</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
<div class="fs-4 fw-bold text-primary">${d.domicilios?.total_periodo||g.total_domicilios||0}</div>
|
||||
<div class="small text-muted">Domicilios</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
<div class="fs-4 fw-bold text-info">${g.total_pacientes||0}</div>
|
||||
<div class="small text-muted">Pacientes</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Barras de órdenes por estado
|
||||
renderBarras('ordenes-estado-chart', d.ordenes?.por_estado || {});
|
||||
// Barras de domicilios por estado
|
||||
renderBarras('domicilios-estado-chart', d.domicilios?.por_estado || {});
|
||||
}
|
||||
|
||||
function renderBarras(elId, datos) {
|
||||
const el = document.getElementById(elId);
|
||||
const total = Object.values(datos).reduce((a,b) => a + (b||0), 0) || 1;
|
||||
if (!Object.keys(datos).length) {
|
||||
el.innerHTML = '<p class="text-muted small text-center">Sin datos</p>'; return;
|
||||
}
|
||||
el.innerHTML = Object.entries(datos).map(([estado, n]) => `
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="text-end" style="min-width:90px">
|
||||
<span class="badge bg-${COLOR[estado]||'secondary'}">${esc(estado)}</span>
|
||||
</div>
|
||||
<div class="flex-grow-1 bg-light rounded" style="height:16px">
|
||||
<div class="rounded h-100 bg-${COLOR[estado]||'secondary'}" style="width:${Math.max((n/total)*100,2)}%;transition:width .3s"></div>
|
||||
</div>
|
||||
<div style="min-width:28px" class="text-end fw-bold small">${n}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function cargarActividad(pag = 1) {
|
||||
logPag = pag;
|
||||
const modulo = document.getElementById('log-modulo').value;
|
||||
const params = new URLSearchParams({
|
||||
page: pag, limit: 30,
|
||||
modulo,
|
||||
desde: fecha('rpt-desde'),
|
||||
hasta: fecha('rpt-hasta'),
|
||||
});
|
||||
|
||||
const r = await fetch(`api/lab/get_actividad.php?${params}`);
|
||||
const d = await r.json();
|
||||
|
||||
const el = document.getElementById('log-body');
|
||||
if (!d.data?.length) {
|
||||
el.innerHTML = '<p class="text-muted text-center py-3 small">Sin actividad en este período</p>';
|
||||
document.getElementById('log-paginacion').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = d.data.map(a => `
|
||||
<div class="log-item">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-${MODULO_COLOR[a.modulo]||'secondary'} badge-modulo">${esc(a.modulo)}</span>
|
||||
<span class="fw-semibold small">${esc(a.admin_nombre||a.admin_username||'Sistema')}</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:.78rem">${esc(a.accion)} ${a.entidad_id ? `#${a.entidad_id}` : ''}</div>
|
||||
<div class="text-muted" style="font-size:.74rem">${formatFecha(a.created_at)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Paginación mínima
|
||||
const { paginas, total } = d;
|
||||
const pagEl = document.getElementById('log-paginacion');
|
||||
if (paginas > 1) {
|
||||
pagEl.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">${total} eventos</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
${pag>1?`<button class="btn btn-outline-secondary" onclick="cargarActividad(${pag-1})"><i class="fas fa-chevron-left"></i></button>`:''}
|
||||
<button class="btn btn-secondary disabled">${pag}/${paginas}</button>
|
||||
${pag<paginas?`<button class="btn btn-outline-secondary" onclick="cargarActividad(${pag+1})"><i class="fas fa-chevron-right"></i></button>`:''}
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
pagEl.innerHTML = `<small class="text-muted">${total} evento(s)</small>`;
|
||||
}
|
||||
}
|
||||
|
||||
function exportar(tipo) {
|
||||
const url = `lab_reportes.php?export=${tipo}&desde=${fecha('rpt-desde')}&hasta=${fecha('rpt-hasta')}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
// ── Utils ──────────────────────────────────────────────────────────────────
|
||||
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||
const fecha = id => document.getElementById(id)?.value || '';
|
||||
const formatFecha = s => s ? new Date(s).toLocaleString('es-CO', {day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}) : '—';
|
||||
|
||||
cargarTodo();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* Verificador de estado del Módulo Laboratorio
|
||||
* Acceso: php lab_status.php (CLI) o browser con sesión admin
|
||||
*/
|
||||
$isCli = php_sapi_name() === 'cli';
|
||||
|
||||
if (!$isCli) {
|
||||
require_once 'config/config.php';
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php'); exit;
|
||||
}
|
||||
}
|
||||
|
||||
require_once 'classes/Database.php';
|
||||
|
||||
$checks = [];
|
||||
$allOk = true;
|
||||
|
||||
// ── 1. Tablas de base de datos ─────────────────────────────────────────────
|
||||
$tables = [
|
||||
'lab_pacientes', 'lab_enfermeras', 'lab_ordenes_medicas',
|
||||
'lab_domicilios', 'lab_asignaciones', 'lab_autorizaciones', 'lab_actividad_admin',
|
||||
];
|
||||
|
||||
$db = Database::getInstance();
|
||||
foreach ($tables as $t) {
|
||||
try {
|
||||
$db->query("SELECT 1 FROM `{$t}` LIMIT 1", []);
|
||||
$checks[] = ['ok' => true, 'msg' => "Tabla `$t` ✔"];
|
||||
} catch (Exception $e) {
|
||||
$checks[] = ['ok' => false, 'msg' => "Tabla `$t` ✘ — NO EXISTE (ejecuta migrations/20260302_lab_run_migrations.php)"];
|
||||
$allOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Archivos de clases ──────────────────────────────────────────────────
|
||||
$classFiles = [
|
||||
'classes/lab/ActividadAdmin.php',
|
||||
'classes/lab/Paciente.php',
|
||||
'classes/lab/Enfermera.php',
|
||||
'classes/lab/OrdenMedica.php',
|
||||
'classes/lab/Domicilio.php',
|
||||
'classes/lab/Asignacion.php',
|
||||
];
|
||||
foreach ($classFiles as $f) {
|
||||
$ok = file_exists(__DIR__ . '/' . $f);
|
||||
if (!$ok) $allOk = false;
|
||||
$checks[] = ['ok' => $ok, 'msg' => "Clase $f " . ($ok ? '✔' : '✘ — FALTA')];
|
||||
}
|
||||
|
||||
// ── 3. Endpoints API ──────────────────────────────────────────────────────
|
||||
$apiFiles = [
|
||||
'api/lab/_helpers.php', 'api/lab/get_pacientes.php', 'api/lab/save_paciente.php',
|
||||
'api/lab/get_ordenes.php', 'api/lab/save_orden.php', 'api/lab/autorizar_orden.php',
|
||||
'api/lab/get_domicilios.php', 'api/lab/save_domicilio.php', 'api/lab/get_enfermeras.php',
|
||||
'api/lab/save_enfermera.php', 'api/lab/get_asignaciones.php', 'api/lab/save_asignacion.php',
|
||||
'api/lab/get_actividad.php', 'api/lab/get_stats.php', 'api/lab/crear_desde_whatsapp.php',
|
||||
];
|
||||
foreach ($apiFiles as $f) {
|
||||
$ok = file_exists(__DIR__ . '/' . $f);
|
||||
if (!$ok) $allOk = false;
|
||||
$checks[] = ['ok' => $ok, 'msg' => "API $f " . ($ok ? '✔' : '✘ — FALTA')];
|
||||
}
|
||||
|
||||
// ── 4. Vistas (páginas PHP) ───────────────────────────────────────────────
|
||||
$views = ['lab_dashboard.php','lab_ordenes.php','lab_pacientes.php','lab_domicilios.php','lab_enfermeras.php','lab_reportes.php'];
|
||||
foreach ($views as $v) {
|
||||
$ok = file_exists(__DIR__ . '/' . $v);
|
||||
if (!$ok) $allOk = false;
|
||||
$checks[] = ['ok' => $ok, 'msg' => "Vista $v " . ($ok ? '✔' : '✘ — FALTA')];
|
||||
}
|
||||
|
||||
// ── 5. Directorio uploads ─────────────────────────────────────────────────
|
||||
$uploadsOk = is_dir(__DIR__ . '/uploads/media') && is_writable(__DIR__ . '/uploads/media');
|
||||
if (!$uploadsOk) $allOk = false;
|
||||
$checks[] = ['ok' => $uploadsOk, 'msg' => 'Directorio uploads/media ' . ($uploadsOk ? '✔ (writable)' : '✘ — no existe o sin permisos de escritura')];
|
||||
|
||||
// ── Salida ─────────────────────────────────────────────────────────────────
|
||||
if ($isCli) {
|
||||
echo "\n=== Módulo Laboratorio — Estado del sistema ===\n\n";
|
||||
foreach ($checks as $c) {
|
||||
echo ($c['ok'] ? ' [OK] ' : ' [!!] ') . $c['msg'] . "\n";
|
||||
}
|
||||
echo "\n" . ($allOk ? "✔ Todo correcto." : "✘ Hay problemas — revisa los puntos marcados con [!!]") . "\n\n";
|
||||
exit($allOk ? 0 : 1);
|
||||
}
|
||||
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Estado Módulo Lab</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="main-content">
|
||||
<header class="content-header">
|
||||
<h1><i class="fas fa-stethoscope text-primary"></i> Estado del Módulo Laboratorio</h1>
|
||||
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
|
||||
</header>
|
||||
<div class="container-fluid py-3">
|
||||
<div class="alert alert-<?= $allOk ? 'success' : 'danger' ?> d-flex align-items-center gap-2 mb-3">
|
||||
<i class="fas fa-<?= $allOk ? 'check-circle' : 'exclamation-triangle' ?> fs-4"></i>
|
||||
<span class="fw-semibold"><?= $allOk ? 'Módulo instalado correctamente' : 'Se encontraron problemas — revisa los elementos marcados en rojo' ?></span>
|
||||
</div>
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light"><tr><th>Verificación</th><th>Estado</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($checks as $c): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($c['msg']) ?></td>
|
||||
<td><span class="badge bg-<?= $c['ok'] ? 'success' : 'danger' ?>"><?= $c['ok'] ? 'OK' : 'ERROR' ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$allOk): ?>
|
||||
<div class="alert alert-warning mt-3">
|
||||
<h6 class="fw-semibold"><i class="fas fa-wrench me-2"></i>Pasos para solucionar:</h6>
|
||||
<ol class="mb-0">
|
||||
<li>Ejecuta las migraciones: <code>php migrations/20260302_lab_run_migrations.php</code></li>
|
||||
<li>Verifica que todos los archivos de <code>classes/lab/</code> y <code>api/lab/</code> existen</li>
|
||||
<li>Asegúrate de que <code>uploads/media/</code> tiene permisos 755 o 775</li>
|
||||
</ol>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($allOk): ?>
|
||||
<div class="mt-3">
|
||||
<a href="lab_dashboard.php" class="btn btn-success"><i class="fas fa-flask me-2"></i>Ir al Módulo Laboratorio</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</main>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
/**
|
||||
* Términos y Condiciones — Historial de Aceptaciones
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||||
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
|
||||
// Filtros
|
||||
$filterEstado = $_GET['estado'] ?? '';
|
||||
$filterFecha = $_GET['fecha'] ?? '';
|
||||
$filterPhone = trim($_GET['phone'] ?? '');
|
||||
|
||||
// Paginación
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$perPage = 50;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Versión activa
|
||||
$activeVersion = $db->fetch("SELECT * FROM terms_versions WHERE activa = 1 ORDER BY id DESC LIMIT 1");
|
||||
|
||||
// Construcción dinámica del WHERE
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($filterEstado !== '') {
|
||||
$where[] = 'ta.estado = :estado';
|
||||
$params[':estado'] = $filterEstado;
|
||||
}
|
||||
if ($filterFecha !== '') {
|
||||
$where[] = 'DATE(ta.fecha_envio) = :fecha';
|
||||
$params[':fecha'] = $filterFecha;
|
||||
}
|
||||
if ($filterPhone !== '') {
|
||||
$where[] = 'ta.phone_number LIKE :phone';
|
||||
$params[':phone'] = '%' . $filterPhone . '%';
|
||||
}
|
||||
|
||||
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$total = (int)($db->fetch(
|
||||
"SELECT COUNT(*) as c FROM terms_acceptance ta $whereClause",
|
||||
$params
|
||||
)['c'] ?? 0);
|
||||
|
||||
$rows = $db->fetchAll(
|
||||
"SELECT ta.id, ta.phone_number, ta.estado, ta.fecha_envio, ta.fecha_respuesta,
|
||||
tv.version AS terms_version,
|
||||
u.name AS user_name
|
||||
FROM terms_acceptance ta
|
||||
LEFT JOIN terms_versions tv ON tv.id = ta.terms_version_id
|
||||
LEFT JOIN users u ON u.id = ta.user_id
|
||||
$whereClause
|
||||
ORDER BY ta.id DESC
|
||||
LIMIT $perPage OFFSET $offset",
|
||||
$params
|
||||
);
|
||||
|
||||
$totalPages = max(1, ceil($total / $perPage));
|
||||
|
||||
} catch (Exception $e) {
|
||||
$rows = [];
|
||||
$total = 0;
|
||||
$totalPages = 1;
|
||||
$activeVersion = null;
|
||||
$dbError = $e->getMessage();
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Términos y Condiciones — Aceptaciones</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
<style>
|
||||
.estado-aceptado { background:#d1e7dd; color:#0f5132; }
|
||||
.estado-rechazado { background:#f8d7da; color:#842029; }
|
||||
.estado-pendiente { background:#fff3cd; color:#664d03; }
|
||||
.estado-badge { font-size:.72rem; padding:.25em .55em; border-radius:.35rem; font-weight:600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="lab_dashboard.php"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_pacientes.php"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_ordenes.php"><i class="fas fa-clipboard-list"></i> Órdenes</a></li>
|
||||
<li><a href="lab_domicilios.php"><i class="fas fa-home"></i> Domicilios</a></li>
|
||||
<li class="active"><a href="lab_terminos.php"><i class="fas fa-file-contract"></i> Términos</a></li>
|
||||
<li><a href="index.php"><i class="fas fa-cog"></i> Configuración</a></li>
|
||||
<li><a href="logout.php"><i class="fas fa-sign-out-alt"></i> Cerrar sesión</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="content-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1><i class="fas fa-file-contract me-2"></i>Términos y Condiciones</h1>
|
||||
<p class="text-muted mb-0">Historial de aceptaciones de usuarios</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="lab_terminos_export.php?<?= http_build_query(array_filter(['estado' => $filterEstado, 'fecha' => $filterFecha, 'phone' => $filterPhone])) ?>"
|
||||
class="btn btn-outline-success btn-sm">
|
||||
<i class="fas fa-file-csv me-1"></i>Exportar CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Versión activa -->
|
||||
<?php if ($activeVersion): ?>
|
||||
<div class="alert alert-info d-flex align-items-center gap-3 mb-4 py-2">
|
||||
<i class="fas fa-info-circle fa-lg"></i>
|
||||
<div>
|
||||
<strong>Versión activa:</strong> <?= htmlspecialchars($activeVersion['version']) ?>
|
||||
<?php if ($activeVersion['documento_url']): ?>
|
||||
— <a href="<?= htmlspecialchars($activeVersion['documento_url']) ?>" target="_blank" class="alert-link">
|
||||
<i class="fas fa-external-link-alt me-1"></i>Ver documento
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($activeVersion['forzar_reenvio']): ?>
|
||||
<span class="badge bg-warning text-dark ms-2">Re-aceptación forzada activa</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($dbError)): ?>
|
||||
<div class="alert alert-danger"><i class="fas fa-exclamation-circle me-1"></i><?= htmlspecialchars($dbError) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Estadísticas rápidas -->
|
||||
<?php
|
||||
$statsRaw = !isset($dbError) ? $db->fetchAll(
|
||||
"SELECT estado, COUNT(*) as total FROM terms_acceptance GROUP BY estado"
|
||||
) : [];
|
||||
$stats = [];
|
||||
foreach ($statsRaw as $s) $stats[$s['estado']] = (int)$s['total'];
|
||||
?>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold text-success"><?= $stats['aceptado'] ?? 0 ?></div>
|
||||
<small class="text-muted">Aceptaciones</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold text-danger"><?= $stats['rechazado'] ?? 0 ?></div>
|
||||
<small class="text-muted">Rechazos</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold text-warning"><?= $stats['pendiente'] ?? 0 ?></div>
|
||||
<small class="text-muted">Pendientes</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold text-primary"><?= $total ?></div>
|
||||
<small class="text-muted">Total registros</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">Teléfono</label>
|
||||
<input type="text" name="phone" class="form-control form-control-sm"
|
||||
value="<?= htmlspecialchars($filterPhone) ?>" placeholder="Buscar teléfono">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">Estado</label>
|
||||
<select name="estado" class="form-select form-select-sm">
|
||||
<option value="">Todos</option>
|
||||
<option value="aceptado" <?= $filterEstado === 'aceptado' ? 'selected' : '' ?>>Aceptados</option>
|
||||
<option value="rechazado" <?= $filterEstado === 'rechazado' ? 'selected' : '' ?>>Rechazados</option>
|
||||
<option value="pendiente" <?= $filterEstado === 'pendiente' ? 'selected' : '' ?>>Pendientes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">Fecha</label>
|
||||
<input type="date" name="fecha" class="form-control form-control-sm"
|
||||
value="<?= htmlspecialchars($filterFecha) ?>">
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-1">
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="fas fa-search me-1"></i>Filtrar</button>
|
||||
<a href="lab_terminos.php" class="btn btn-outline-secondary btn-sm">Limpiar</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Teléfono</th>
|
||||
<th>Nombre</th>
|
||||
<th>Versión</th>
|
||||
<th>Estado</th>
|
||||
<th>Enviado</th>
|
||||
<th>Respondido</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($rows)): ?>
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No hay registros para los filtros seleccionados.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($rows as $r): ?>
|
||||
<tr>
|
||||
<td class="text-muted small"><?= (int)$r['id'] ?></td>
|
||||
<td><code><?= htmlspecialchars($r['phone_number']) ?></code></td>
|
||||
<td><?= htmlspecialchars($r['user_name'] ?: '—') ?></td>
|
||||
<td><?= htmlspecialchars($r['terms_version'] ?: '—') ?></td>
|
||||
<td>
|
||||
<span class="estado-badge estado-<?= htmlspecialchars($r['estado']) ?>">
|
||||
<?= ucfirst(htmlspecialchars($r['estado'])) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="small text-muted"><?= $r['fecha_envio'] ? date('d/m/Y H:i', strtotime($r['fecha_envio'])) : '—' ?></td>
|
||||
<td class="small text-muted"><?= $r['fecha_respuesta'] ? date('d/m/Y H:i', strtotime($r['fecha_respuesta'])) : '—' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Paginación -->
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center py-2">
|
||||
<small class="text-muted">Página <?= $page ?> de <?= $totalPages ?> — <?= $total ?> registros</small>
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<?php for ($p = 1; $p <= $totalPages; $p++): ?>
|
||||
<li class="page-item <?= $p === $page ? 'active' : '' ?>">
|
||||
<a class="page-link" href="?<?= http_build_query(array_merge($_GET, ['page' => $p])) ?>"><?= $p ?></a>
|
||||
</li>
|
||||
<?php endfor; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* Exportar aceptaciones de T&C a CSV
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$filterEstado = $_GET['estado'] ?? '';
|
||||
$filterFecha = trim($_GET['fecha'] ?? '');
|
||||
$filterPhone = trim($_GET['phone'] ?? '');
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($filterEstado !== '') {
|
||||
$where[] = 'ta.estado = :estado';
|
||||
$params[':estado'] = $filterEstado;
|
||||
}
|
||||
if ($filterFecha !== '') {
|
||||
$where[] = 'DATE(ta.fecha_envio) = :fecha';
|
||||
$params[':fecha'] = $filterFecha;
|
||||
}
|
||||
if ($filterPhone !== '') {
|
||||
$where[] = 'ta.phone_number LIKE :phone';
|
||||
$params[':phone'] = '%' . $filterPhone . '%';
|
||||
}
|
||||
|
||||
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$rows = $db->fetchAll(
|
||||
"SELECT ta.id, ta.phone_number, u.name AS user_name, ta.estado,
|
||||
tv.version AS terms_version,
|
||||
ta.fecha_envio, ta.fecha_respuesta
|
||||
FROM terms_acceptance ta
|
||||
LEFT JOIN terms_versions tv ON tv.id = ta.terms_version_id
|
||||
LEFT JOIN users u ON u.id = ta.user_id
|
||||
$whereClause
|
||||
ORDER BY ta.id DESC",
|
||||
$params
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo 'Error: ' . htmlspecialchars($e->getMessage());
|
||||
exit;
|
||||
}
|
||||
|
||||
$filename = 'terminos_aceptaciones_' . date('Ymd_His') . '.csv';
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
// BOM para Excel
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
|
||||
fputcsv($out, ['ID', 'Teléfono', 'Nombre', 'Estado', 'Versión Términos', 'Fecha Envío', 'Fecha Respuesta']);
|
||||
|
||||
foreach ($rows as $r) {
|
||||
fputcsv($out, [
|
||||
$r['id'],
|
||||
$r['phone_number'],
|
||||
$r['user_name'] ?? '',
|
||||
$r['estado'],
|
||||
$r['terms_version'] ?? '',
|
||||
$r['fecha_envio'] ?? '',
|
||||
$r['fecha_respuesta'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
@@ -0,0 +1,554 @@
|
||||
<?php
|
||||
/**
|
||||
* Gestión de Usuarios y Roles
|
||||
* Solo accesible para administradores.
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) { header('Location: login.php'); exit; }
|
||||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||||
|
||||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||||
|
||||
// Catálogo de módulos disponible desde PHP (para el <script>)
|
||||
$systemModules = SYSTEM_MODULES;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Usuarios y Roles — Módulo Lab</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||||
<style>
|
||||
.role-badge { display:inline-block; padding:.25em .65em; border-radius:6px; font-size:.78rem; font-weight:600; color:#fff; }
|
||||
.module-chip { display:inline-block; padding:.15em .55em; border-radius:4px; font-size:.72rem; background:#e9ecef; color:#495057; margin:1px; }
|
||||
.module-check-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:.5rem; }
|
||||
.module-check-grid .form-check { background:#f8f9fa; border-radius:6px; padding:.5rem .75rem .5rem 2rem; border:1px solid #dee2e6; }
|
||||
.module-check-grid .form-check:has(input:checked) { background:#e7f1ff; border-color:#0d6efd; }
|
||||
.color-preview { width:28px; height:28px; border-radius:50%; display:inline-block; vertical-align:middle; border:2px solid #dee2e6; }
|
||||
.table-actions { white-space:nowrap; }
|
||||
.user-avatar { width:36px; height:36px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:.85rem; color:#fff; }
|
||||
.badge-active { background:#198754; }
|
||||
.badge-inactive { background:#6c757d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
|
||||
<ul class="sidebar-menu">
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_ordenes.php" class="nav-link"><i class="fas fa-file-medical"></i> Órdenes</a></li>
|
||||
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<li><a href="lab_enfermeras.php" class="nav-link"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
|
||||
<li><a href="lab_formularios.php" class="nav-link"><i class="fas fa-wpforms"></i> Formularios</a></li>
|
||||
<li><a href="lab_reportes.php" class="nav-link"><i class="fas fa-chart-bar"></i> Reportes</a></li>
|
||||
<li><a href="lab_configuracion.php"class="nav-link"><i class="fas fa-sliders-h"></i> Configuración</a></li>
|
||||
<li><a href="lab_usuarios.php" class="nav-link active"><i class="fas fa-users-cog"></i> Usuarios</a></li>
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
|
||||
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="content-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h1><i class="fas fa-users-cog text-primary"></i> Usuarios & Roles</h1>
|
||||
<small class="text-muted"><i class="fas fa-user"></i> <?= htmlspecialchars($adminNombre) ?> — <?= date('d/m/Y') ?></small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs mb-3" id="mainTabs">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-usuarios">
|
||||
<i class="fas fa-user me-1"></i> Usuarios
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-roles">
|
||||
<i class="fas fa-shield-alt me-1"></i> Roles
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
|
||||
<!-- ═══════════════ TAB USUARIOS ═══════════════ -->
|
||||
<div class="tab-pane fade show active" id="tab-usuarios">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0 fw-semibold">Listado de usuarios del sistema</h6>
|
||||
<button class="btn btn-primary btn-sm" onclick="usuarios.abrirNuevo()">
|
||||
<i class="fas fa-plus me-1"></i> Nuevo Usuario
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0" id="tablaUsuarios">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:44px"></th>
|
||||
<th>Usuario</th>
|
||||
<th>Nombre</th>
|
||||
<th>Rol</th>
|
||||
<th>Estado</th>
|
||||
<th>Último acceso</th>
|
||||
<th class="text-end">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usuariosTbody">
|
||||
<tr><td colspan="7" class="text-center py-4 text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>Cargando…
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-usuarios -->
|
||||
|
||||
<!-- ═══════════════ TAB ROLES ═══════════════ -->
|
||||
<div class="tab-pane fade" id="tab-roles">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0 fw-semibold">Roles y permisos de módulo</h6>
|
||||
<button class="btn btn-primary btn-sm" onclick="roles.abrirNuevo()">
|
||||
<i class="fas fa-plus me-1"></i> Nuevo Rol
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="rolesGrid" class="row g-3">
|
||||
<div class="col-12 text-center py-4 text-muted">
|
||||
<i class="fas fa-spinner fa-spin me-2"></i>Cargando…
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-roles -->
|
||||
|
||||
</div><!-- /tab-content -->
|
||||
</div><!-- /container -->
|
||||
</main>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
MODAL — USUARIO
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<div class="modal fade" id="modalUsuario" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modalUsuarioTitulo">Nuevo Usuario</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="u_id">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Username <span class="text-danger">*</span></label>
|
||||
<input type="text" id="u_username" class="form-control" placeholder="ej. juan.perez" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Nombre completo <span class="text-danger">*</span></label>
|
||||
<input type="text" id="u_fullname" class="form-control" placeholder="Juan Pérez">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Correo electrónico</label>
|
||||
<input type="email" id="u_email" class="form-control" placeholder="correo@dominio.com">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold" id="u_pass_label">Contraseña <span class="text-danger">*</span></label>
|
||||
<input type="password" id="u_password" class="form-control" autocomplete="new-password">
|
||||
<div class="form-text text-muted" id="u_pass_hint" style="display:none">
|
||||
Deja en blanco para conservar la contraseña actual
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Rol <span class="text-danger">*</span></label>
|
||||
<select id="u_role_id" class="form-select" onchange="usuarios.onRoleChange(this.value)">
|
||||
<option value="">Seleccionar rol…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Estado</label>
|
||||
<select id="u_is_active" class="form-select">
|
||||
<option value="1">Activo</option>
|
||||
<option value="0">Inactivo</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Solo visible cuando es rol enfermero -->
|
||||
<div class="col-12" id="u_enf_row" style="display:none">
|
||||
<label class="form-label fw-semibold">Enfermera vinculada</label>
|
||||
<select id="u_enfermera_id" class="form-select">
|
||||
<option value="">Sin vincular</option>
|
||||
</select>
|
||||
<div class="form-text">Vincula este usuario con un registro de enfermera para que vea sus domicilios</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-danger mt-3 d-none" id="u_error"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary" id="u_btn_guardar" onclick="usuarios.guardar()">
|
||||
<i class="fas fa-save me-1"></i> Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
MODAL — ROL
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<div class="modal fade" id="modalRol" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modalRolTitulo">Nuevo Rol</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="r_id">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label fw-semibold">Nombre del rol <span class="text-danger">*</span></label>
|
||||
<input type="text" id="r_name" class="form-control" placeholder="ej. Supervisor" oninput="roles.autoSlug()">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Slug <span class="text-danger">*</span></label>
|
||||
<input type="text" id="r_slug" class="form-control" placeholder="supervisor">
|
||||
<div class="form-text">Solo letras, números y guiones</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">Color</label>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="color" id="r_color" class="form-control form-control-color" value="#0d6efd" style="width:56px;height:38px">
|
||||
<span class="text-muted small">Insignia del rol</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Descripción</label>
|
||||
<textarea id="r_description" class="form-control" rows="2" placeholder="Descripción corta del rol"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-label fw-semibold">Módulos permitidos</label>
|
||||
<div class="module-check-grid" id="r_modules_grid">
|
||||
<!-- Se inyecta por JS -->
|
||||
</div>
|
||||
<div class="alert alert-danger mt-3 d-none" id="r_error"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||
<button type="button" class="btn btn-primary" onclick="roles.guardar()">
|
||||
<i class="fas fa-save me-1"></i> Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// ── Módulos del sistema (desde PHP) ──────────────────────────────────────────
|
||||
const SYSTEM_MODULES = <?= json_encode($systemModules, JSON_UNESCAPED_UNICODE) ?>;
|
||||
|
||||
// ── Datos en memoria ─────────────────────────────────────────────────────────
|
||||
let allRoles = [];
|
||||
let allUsers = [];
|
||||
let enfermeras = [];
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// GESTIÓN DE ROLES
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
const roles = {
|
||||
|
||||
async cargar() {
|
||||
const res = await fetch('api/lab/get_roles.php').then(r => r.json());
|
||||
allRoles = res.roles ?? [];
|
||||
this.render();
|
||||
// Refrescar selector de rol en modal de usuario
|
||||
usuarios.refrescarSelectRoles();
|
||||
},
|
||||
|
||||
render() {
|
||||
const grid = document.getElementById('rolesGrid');
|
||||
if (!allRoles.length) {
|
||||
grid.innerHTML = '<div class="col-12 text-center text-muted py-4">No hay roles creados.</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = allRoles.map(r => {
|
||||
const modChips = r.modules.map(m =>
|
||||
`<span class="module-chip">${SYSTEM_MODULES[m] ?? m}</span>`
|
||||
).join('');
|
||||
const editBtn = `<button class="btn btn-sm btn-outline-primary" onclick="roles.editar(${r.id})"><i class="fas fa-edit"></i> Editar</button>`;
|
||||
const deleteBtn = r.is_system ? '' :
|
||||
`<button class="btn btn-sm btn-outline-danger ms-1" onclick="roles.eliminar(${r.id},'${esc(r.name)}')"><i class="fas fa-trash-alt"></i></button>`;
|
||||
return `
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<span class="role-badge" style="background:${r.color ?? '#6c757d'}">${esc(r.name)}</span>
|
||||
${r.is_system ? '<span class="badge bg-secondary">sistema</span>':''}
|
||||
<span class="ms-auto text-muted small">${r.user_count} usuario${r.user_count!==1?'s':''}</span>
|
||||
</div>
|
||||
<p class="text-muted small mb-2">${esc(r.description ?? '')}</p>
|
||||
<div class="mb-3">${modChips || '<span class="text-muted small">Sin módulos</span>'}</div>
|
||||
<div class="d-flex">${editBtn}${deleteBtn}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
},
|
||||
|
||||
abrirNuevo() {
|
||||
document.getElementById('r_id').value = '';
|
||||
document.getElementById('r_name').value = '';
|
||||
document.getElementById('r_slug').value = '';
|
||||
document.getElementById('r_description').value = '';
|
||||
document.getElementById('r_color').value = '#0d6efd';
|
||||
document.getElementById('r_error').classList.add('d-none');
|
||||
this._buildModuleGrid([]);
|
||||
document.getElementById('modalRolTitulo').textContent = 'Nuevo Rol';
|
||||
new bootstrap.Modal('#modalRol').show();
|
||||
},
|
||||
|
||||
editar(id) {
|
||||
const r = allRoles.find(x => x.id === id);
|
||||
if (!r) return;
|
||||
document.getElementById('r_id').value = r.id;
|
||||
document.getElementById('r_name').value = r.name;
|
||||
document.getElementById('r_slug').value = r.slug;
|
||||
document.getElementById('r_description').value = r.description ?? '';
|
||||
document.getElementById('r_color').value = r.color ?? '#0d6efd';
|
||||
document.getElementById('r_error').classList.add('d-none');
|
||||
this._buildModuleGrid(r.modules);
|
||||
document.getElementById('modalRolTitulo').textContent = 'Editar Rol: ' + r.name;
|
||||
new bootstrap.Modal('#modalRol').show();
|
||||
},
|
||||
|
||||
_buildModuleGrid(activeModules) {
|
||||
const grid = document.getElementById('r_modules_grid');
|
||||
grid.innerHTML = Object.entries(SYSTEM_MODULES).map(([slug, label]) => {
|
||||
const checked = activeModules.includes(slug) ? 'checked' : '';
|
||||
return `<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="mod_${slug}" value="${slug}" ${checked}>
|
||||
<label class="form-check-label small" for="mod_${slug}">${label}</label>
|
||||
</div>`;
|
||||
}).join('');
|
||||
},
|
||||
|
||||
autoSlug() {
|
||||
const name = document.getElementById('r_name').value;
|
||||
const slugEl = document.getElementById('r_slug');
|
||||
if (!slugEl.dataset.manual) {
|
||||
slugEl.value = name.toLowerCase()
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g,'')
|
||||
.replace(/[^a-z0-9]+/g,'_').replace(/^_|_$/g,'');
|
||||
}
|
||||
},
|
||||
|
||||
async guardar() {
|
||||
const id = document.getElementById('r_id').value;
|
||||
const name = document.getElementById('r_name').value.trim();
|
||||
const slug = document.getElementById('r_slug').value.trim();
|
||||
const desc = document.getElementById('r_description').value.trim();
|
||||
const color = document.getElementById('r_color').value;
|
||||
const errEl = document.getElementById('r_error');
|
||||
errEl.classList.add('d-none');
|
||||
|
||||
const modules = [...document.querySelectorAll('#r_modules_grid input:checked')].map(el => el.value);
|
||||
|
||||
const body = { name, slug, description: desc, color, modules };
|
||||
if (id) body.id = parseInt(id);
|
||||
|
||||
const res = await fetch('api/lab/save_role.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => r.json());
|
||||
|
||||
if (!res.ok) { errEl.textContent = res.error; errEl.classList.remove('d-none'); return; }
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('modalRol'))?.hide();
|
||||
await this.cargar();
|
||||
},
|
||||
|
||||
async eliminar(id, nombre) {
|
||||
if (!confirm(`¿Eliminar el rol "${nombre}"?\nLos usuarios con este rol pasarán al rol Administrador.`)) return;
|
||||
const res = await fetch('api/lab/delete_role.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({id})
|
||||
}).then(r => r.json());
|
||||
if (!res.ok) { alert(res.error); return; }
|
||||
await this.cargar();
|
||||
await usuarios.cargar();
|
||||
}
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// GESTIÓN DE USUARIOS
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
const usuarios = {
|
||||
|
||||
async cargar() {
|
||||
const res = await fetch('api/lab/get_lab_users.php').then(r => r.json());
|
||||
allUsers = res.users ?? [];
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const tbody = document.getElementById('usuariosTbody');
|
||||
if (!allUsers.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">No hay usuarios.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = allUsers.map(u => {
|
||||
const initials = (u.full_name ?? u.username).split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase();
|
||||
const color = u.role_color ?? '#0d6efd';
|
||||
const avatar = `<span class="user-avatar" style="background:${color}">${initials}</span>`;
|
||||
const roleTag = `<span class="role-badge" style="background:${color}">${esc(u.role_name ?? u.role)}</span>`;
|
||||
const statusTag= u.is_active
|
||||
? '<span class="badge badge-active">Activo</span>'
|
||||
: '<span class="badge badge-inactive">Inactivo</span>';
|
||||
const lastLogin = u.last_login ? u.last_login.slice(0,16).replace('T',' ') : '—';
|
||||
return `<tr>
|
||||
<td>${avatar}</td>
|
||||
<td><strong>${esc(u.username)}</strong></td>
|
||||
<td>${esc(u.full_name ?? '—')}</td>
|
||||
<td>${roleTag}</td>
|
||||
<td>${statusTag}</td>
|
||||
<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 btn-outline-danger ms-1" onclick="usuarios.eliminar(${u.id},'${esc(u.username)}')"><i class="fas fa-trash-alt"></i></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
},
|
||||
|
||||
refrescarSelectRoles() {
|
||||
const sel = document.getElementById('u_role_id');
|
||||
const curr = sel.value;
|
||||
sel.innerHTML = '<option value="">Seleccionar rol…</option>' +
|
||||
allRoles.map(r => `<option value="${r.id}">${esc(r.name)}</option>`).join('');
|
||||
if (curr) sel.value = curr;
|
||||
},
|
||||
|
||||
abrirNuevo() {
|
||||
document.getElementById('u_id').value = '';
|
||||
document.getElementById('u_username').value = '';
|
||||
document.getElementById('u_fullname').value = '';
|
||||
document.getElementById('u_email').value = '';
|
||||
document.getElementById('u_password').value = '';
|
||||
document.getElementById('u_role_id').value = '';
|
||||
document.getElementById('u_is_active').value = '1';
|
||||
document.getElementById('u_pass_label').innerHTML = 'Contraseña <span class="text-danger">*</span>';
|
||||
document.getElementById('u_pass_hint').style.display = 'none';
|
||||
document.getElementById('u_error').classList.add('d-none');
|
||||
document.getElementById('u_enf_row').style.display = 'none';
|
||||
document.getElementById('modalUsuarioTitulo').textContent = 'Nuevo Usuario';
|
||||
new bootstrap.Modal('#modalUsuario').show();
|
||||
},
|
||||
|
||||
editar(id) {
|
||||
const u = allUsers.find(x => x.id === id);
|
||||
if (!u) return;
|
||||
document.getElementById('u_id').value = u.id;
|
||||
document.getElementById('u_username').value = u.username;
|
||||
document.getElementById('u_fullname').value = u.full_name ?? '';
|
||||
document.getElementById('u_email').value = u.email ?? '';
|
||||
document.getElementById('u_password').value = '';
|
||||
document.getElementById('u_role_id').value = u.role_id ?? '';
|
||||
document.getElementById('u_is_active').value = u.is_active ? '1' : '0';
|
||||
document.getElementById('u_pass_label').innerHTML = 'Nueva contraseña';
|
||||
document.getElementById('u_pass_hint').style.display = 'block';
|
||||
document.getElementById('u_error').classList.add('d-none');
|
||||
document.getElementById('modalUsuarioTitulo').textContent = 'Editar: ' + u.username;
|
||||
this.onRoleChange(u.role_id, u.enfermera_id);
|
||||
new bootstrap.Modal('#modalUsuario').show();
|
||||
},
|
||||
|
||||
onRoleChange(roleId, currentEnfId = null) {
|
||||
const role = allRoles.find(r => r.id === parseInt(roleId));
|
||||
const enfRow = document.getElementById('u_enf_row');
|
||||
if (role?.slug === 'enfermero') {
|
||||
enfRow.style.display = '';
|
||||
this._cargarEnfermeras(currentEnfId);
|
||||
} else {
|
||||
enfRow.style.display = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
async _cargarEnfermeras(selectedId = null) {
|
||||
if (!enfermeras.length) {
|
||||
const res = await fetch('api/lab/get_enfermeras.php').then(r => r.json());
|
||||
enfermeras = res.data ?? res.enfermeras ?? [];
|
||||
}
|
||||
const sel = document.getElementById('u_enfermera_id');
|
||||
sel.innerHTML = '<option value="">Sin vincular</option>' +
|
||||
enfermeras.map(e => `<option value="${e.id}" ${e.id == selectedId ? 'selected':''}>${esc(e.nombre_completo)}</option>`).join('');
|
||||
},
|
||||
|
||||
async guardar() {
|
||||
const id = document.getElementById('u_id').value;
|
||||
const errEl = document.getElementById('u_error');
|
||||
errEl.classList.add('d-none');
|
||||
|
||||
const body = {
|
||||
username : document.getElementById('u_username').value.trim(),
|
||||
full_name : document.getElementById('u_fullname').value.trim(),
|
||||
email : document.getElementById('u_email').value.trim(),
|
||||
password : document.getElementById('u_password').value,
|
||||
role_id : parseInt(document.getElementById('u_role_id').value) || null,
|
||||
is_active : parseInt(document.getElementById('u_is_active').value),
|
||||
enfermera_id: document.getElementById('u_enfermera_id').value || null,
|
||||
};
|
||||
if (id) body.id = parseInt(id);
|
||||
|
||||
const res = await fetch('api/lab/save_lab_user.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => r.json());
|
||||
|
||||
if (!res.ok) { errEl.textContent = res.error; errEl.classList.remove('d-none'); return; }
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('modalUsuario'))?.hide();
|
||||
await this.cargar();
|
||||
},
|
||||
|
||||
async eliminar(id, username) {
|
||||
if (!confirm(`¿Eliminar el usuario "${username}"? Esta acción no se puede deshacer.`)) return;
|
||||
const res = await fetch('api/lab/delete_lab_user.php', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({id})
|
||||
}).then(r => r.json());
|
||||
if (!res.ok) { alert(res.error); return; }
|
||||
await this.cargar();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Utilidad ─────────────────────────────────────────────────────────────────
|
||||
function esc(str) {
|
||||
return String(str ?? '').replace(/[&<>"']/g, c =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
// Marca manual en slug para no sobreescribir si el usuario lo editó
|
||||
document.getElementById('r_slug').addEventListener('input', function() {
|
||||
this.dataset.manual = this.value ? '1' : '';
|
||||
});
|
||||
|
||||
// ── Inicialización ────────────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
await roles.cargar();
|
||||
await usuarios.cargar();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -54,14 +54,17 @@ if ($_POST && !$loginBlocked) {
|
||||
$_SESSION['login_ip'] = $clientIp;
|
||||
$_SESSION['login_time'] = time();
|
||||
|
||||
header('Location: index.php');
|
||||
// Redirigir según el rol
|
||||
if (($adminUser['role'] ?? 'admin') === 'enfermero') {
|
||||
header('Location: enfermero_portal.php');
|
||||
} else {
|
||||
header('Location: index.php');
|
||||
}
|
||||
exit;
|
||||
} else {
|
||||
// Login fallido
|
||||
recordFailedLogin($clientIp);
|
||||
$error = 'Usuario o contraseña incorrectos.';
|
||||
|
||||
// Verificar si se bloqueó después de este intento
|
||||
if (!checkLoginAttempts($clientIp)) {
|
||||
$loginBlocked = true;
|
||||
$error = 'Demasiados intentos de login. Cuenta bloqueada por ' . (LOGIN_LOCKOUT_TIME / 60) . ' minutos.';
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
01_backup_full.sql
|
||||
02_add_template_components.sql
|
||||
03_create_scheduled_messages.sql
|
||||
04_add_domicilio_cobro_fields.sql
|
||||
05_terms_acceptance.sql
|
||||
06_master_sync.sql
|
||||
|
||||
Binary file not shown.
@@ -232,3 +232,12 @@ Stack trace:
|
||||
[2026-02-03 20:47:36] [INFO] Mensaje programado creado: ID=8, Usuario=3250, Fecha=2026-02-03 20:48
|
||||
[2026-02-03 20:54:33] [INFO] Mensaje programado creado: ID=9, Usuario=3250, Fecha=2026-02-03 20:55
|
||||
[2026-02-06 10:23:52] [INFO] Cleared advisor_requested for user 3250 after outgoing message by operator
|
||||
[2026-03-10 15:42:57] [INFO] Configuración de WhatsApp actualizada {"configured_fields":{"token":true,"phone_id":true,"webhook_token":true},"user":"debug_user"}
|
||||
[2026-03-10 16:23:28] [INFO] Configuración de WhatsApp actualizada {"configured_fields":{"token":true,"phone_id":true,"webhook_token":true},"user":"debug_user"}
|
||||
[2026-03-10 16:25:44] [INFO] Configuración de WhatsApp actualizada {"configured_fields":{"token":true,"phone_id":true,"webhook_token":true},"user":"debug_user"}
|
||||
[2026-03-10 16:25:53] [INFO] Configuración de WhatsApp actualizada {"configured_fields":{"token":true,"phone_id":true,"webhook_token":true},"user":"debug_user"}
|
||||
[2026-03-10 18:13:49] [INFO] Configuración de WhatsApp actualizada {"configured_fields":{"token":true,"phone_id":true,"webhook_token":true},"user":"debug_user"}
|
||||
[2026-03-10 18:14:09] [INFO] Plantillas sincronizadas desde Facebook: 0 nuevas, 0 actualizadas, 0 sin cambios
|
||||
[2026-03-10 18:16:17] [INFO] Plantillas sincronizadas desde Facebook: 0 nuevas, 0 actualizadas, 0 sin cambios
|
||||
[2026-03-10 18:17:09] [INFO] Plantillas sincronizadas desde Facebook: 0 nuevas, 0 actualizadas, 0 sin cambios
|
||||
[2026-03-10 18:23:17] [INFO] Plantillas sincronizadas desde Facebook: 10 nuevas, 1 actualizadas, 0 sin cambios
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260302_lab_01_pacientes
|
||||
-- Módulo Administrativo: Pacientes del laboratorio
|
||||
-- Extiende la tabla users (contactos WhatsApp existentes)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lab_pacientes` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT(11) DEFAULT NULL COMMENT 'FK a users.id (teléfono WhatsApp)',
|
||||
`numero_documento` VARCHAR(30) DEFAULT NULL COMMENT 'Cédula / NIT / Pasaporte',
|
||||
`tipo_documento` ENUM('CC','CE','TI','PA','NIT','RC','MS') DEFAULT 'CC',
|
||||
`nombre_completo` VARCHAR(150) NOT NULL,
|
||||
`telefono` VARCHAR(20) DEFAULT NULL,
|
||||
`email` VARCHAR(100) DEFAULT NULL,
|
||||
`fecha_nacimiento` DATE DEFAULT NULL,
|
||||
`genero` ENUM('M','F','O') DEFAULT NULL,
|
||||
`direccion` TEXT DEFAULT NULL,
|
||||
`ciudad` VARCHAR(100) DEFAULT NULL,
|
||||
`barrio` VARCHAR(100) DEFAULT NULL,
|
||||
`eps` VARCHAR(100) DEFAULT NULL COMMENT 'Entidad promotora de salud',
|
||||
`notas_admin` TEXT DEFAULT NULL COMMENT 'Notas internas del administrador',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_numero_documento` (`numero_documento`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_nombre` (`nombre_completo`),
|
||||
KEY `idx_telefono` (`telefono`),
|
||||
KEY `idx_documento` (`numero_documento`),
|
||||
CONSTRAINT `fk_lab_pac_user` FOREIGN KEY (`user_id`)
|
||||
REFERENCES `users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Pacientes del laboratorio (módulo administrativo)';
|
||||
@@ -0,0 +1,24 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260302_lab_02_enfermeras
|
||||
-- Módulo Administrativo: Personal de enfermería
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lab_enfermeras` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`numero_documento` VARCHAR(30) NOT NULL,
|
||||
`tipo_documento` ENUM('CC','CE','TI','PA') DEFAULT 'CC',
|
||||
`nombre_completo` VARCHAR(150) NOT NULL,
|
||||
`telefono` VARCHAR(20) NOT NULL,
|
||||
`telefono_alt` VARCHAR(20) DEFAULT NULL,
|
||||
`email` VARCHAR(100) DEFAULT NULL,
|
||||
`zona` VARCHAR(100) DEFAULT NULL COMMENT 'Zona/barrio asignada habitualmente',
|
||||
`notas` TEXT DEFAULT NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_enf_documento` (`numero_documento`),
|
||||
KEY `idx_enf_nombre` (`nombre_completo`),
|
||||
KEY `idx_enf_activa` (`is_active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Personal de enfermería para domicilios';
|
||||
@@ -0,0 +1,56 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260302_lab_03_ordenes_medicas
|
||||
-- Módulo Administrativo: Órdenes médicas recibidas por WhatsApp
|
||||
-- Vincula una imagen del chat con un paciente y un estado
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lab_ordenes_medicas` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`paciente_id` INT(11) NOT NULL COMMENT 'FK a lab_pacientes.id',
|
||||
`conversation_id` INT(11) DEFAULT NULL COMMENT 'FK a conversations.id (mensaje con la imagen)',
|
||||
`whatsapp_media_id` VARCHAR(255) DEFAULT NULL COMMENT 'Media ID de WhatsApp para referencia',
|
||||
`local_file` VARCHAR(255) DEFAULT NULL COMMENT 'Ruta local de la imagen de la orden',
|
||||
-- Estado del flujo de revisión
|
||||
`estado` ENUM(
|
||||
'pendiente',
|
||||
'en_revision',
|
||||
'autorizada',
|
||||
'rechazada',
|
||||
'en_domicilio',
|
||||
'completada'
|
||||
) NOT NULL DEFAULT 'pendiente',
|
||||
-- Datos de la orden médica (ingresados manualmente)
|
||||
`medico_nombre` VARCHAR(150) DEFAULT NULL,
|
||||
`medico_registro` VARCHAR(50) DEFAULT NULL,
|
||||
`fecha_orden` DATE DEFAULT NULL COMMENT 'Fecha que aparece en la orden impresa',
|
||||
`diagnostico` TEXT DEFAULT NULL,
|
||||
`examenes_solicitados` TEXT DEFAULT NULL COMMENT 'Lista de exámenes de la orden',
|
||||
`requiere_ayuno` TINYINT(1) DEFAULT 0,
|
||||
`horas_ayuno` TINYINT(3) DEFAULT NULL,
|
||||
`indicaciones` TEXT DEFAULT NULL,
|
||||
-- Gestión administrativa
|
||||
`revisada_por` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id',
|
||||
`revisada_at` DATETIME DEFAULT NULL,
|
||||
`autorizada_por` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id',
|
||||
`autorizada_at` DATETIME DEFAULT NULL,
|
||||
`comentario_revision` TEXT DEFAULT NULL,
|
||||
`notas_admin` TEXT DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ord_paciente` (`paciente_id`),
|
||||
KEY `idx_ord_conversation` (`conversation_id`),
|
||||
KEY `idx_ord_estado` (`estado`),
|
||||
KEY `idx_ord_fecha` (`fecha_orden`),
|
||||
KEY `idx_ord_revisada_por` (`revisada_por`),
|
||||
KEY `idx_ord_autorizada` (`autorizada_por`),
|
||||
CONSTRAINT `fk_ord_paciente` FOREIGN KEY (`paciente_id`)
|
||||
REFERENCES `lab_pacientes` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_ord_conversation` FOREIGN KEY (`conversation_id`)
|
||||
REFERENCES `conversations` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_ord_revisada` FOREIGN KEY (`revisada_por`)
|
||||
REFERENCES `admin_users` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_ord_autorizada` FOREIGN KEY (`autorizada_por`)
|
||||
REFERENCES `admin_users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Órdenes médicas recibidas vía WhatsApp';
|
||||
@@ -0,0 +1,53 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260302_lab_04_domicilios
|
||||
-- Módulo Administrativo: Servicios a domicilio
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lab_domicilios` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`orden_id` INT(11) DEFAULT NULL COMMENT 'FK a lab_ordenes_medicas.id',
|
||||
`paciente_id` INT(11) NOT NULL COMMENT 'FK a lab_pacientes.id',
|
||||
-- Datos del domicilio
|
||||
`direccion` TEXT NOT NULL,
|
||||
`ciudad` VARCHAR(100) DEFAULT NULL,
|
||||
`barrio` VARCHAR(100) DEFAULT NULL,
|
||||
`indicaciones_dir` TEXT DEFAULT NULL COMMENT 'Referencia de la dirección',
|
||||
`fecha_programada` DATE NOT NULL,
|
||||
`hora_programada` TIME DEFAULT NULL,
|
||||
`tipo_servicio` VARCHAR(100) DEFAULT NULL COMMENT 'Tipo de toma de muestra',
|
||||
-- Estado del domicilio
|
||||
`estado` ENUM(
|
||||
'programado',
|
||||
'confirmado',
|
||||
'en_camino',
|
||||
'en_domicilio',
|
||||
'completado',
|
||||
'cancelado',
|
||||
'reprogramado'
|
||||
) NOT NULL DEFAULT 'programado',
|
||||
`motivo_cancelacion` TEXT DEFAULT NULL,
|
||||
`fecha_reprogramada` DATE DEFAULT NULL,
|
||||
-- Resultados
|
||||
`hora_llegada` TIME DEFAULT NULL,
|
||||
`hora_salida` TIME DEFAULT NULL,
|
||||
`observaciones` TEXT DEFAULT NULL,
|
||||
`muestras_tomadas` TEXT DEFAULT NULL COMMENT 'Lista de muestras obtenidas',
|
||||
-- Gestión
|
||||
`creado_por` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id',
|
||||
`notas_admin` TEXT DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_dom_orden` (`orden_id`),
|
||||
KEY `idx_dom_paciente` (`paciente_id`),
|
||||
KEY `idx_dom_fecha` (`fecha_programada`),
|
||||
KEY `idx_dom_estado` (`estado`),
|
||||
KEY `idx_dom_creado` (`creado_por`),
|
||||
CONSTRAINT `fk_dom_orden` FOREIGN KEY (`orden_id`)
|
||||
REFERENCES `lab_ordenes_medicas` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_dom_paciente` FOREIGN KEY (`paciente_id`)
|
||||
REFERENCES `lab_pacientes` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_dom_creado` FOREIGN KEY (`creado_por`)
|
||||
REFERENCES `admin_users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Servicios a domicilio programados';
|
||||
@@ -0,0 +1,33 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260302_lab_05_asignaciones
|
||||
-- Módulo Administrativo: Asignación de enfermera a domicilio
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lab_asignaciones` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`domicilio_id` INT(11) NOT NULL COMMENT 'FK a lab_domicilios.id',
|
||||
`enfermera_id` INT(11) NOT NULL COMMENT 'FK a lab_enfermeras.id',
|
||||
`asignada_por` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id',
|
||||
`estado` ENUM(
|
||||
'asignada',
|
||||
'confirmada',
|
||||
'liberada',
|
||||
'completada'
|
||||
) NOT NULL DEFAULT 'asignada',
|
||||
`notas` TEXT DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
-- Solo una enfermera activa por domicilio
|
||||
UNIQUE KEY `uq_asig_domicilio` (`domicilio_id`),
|
||||
KEY `idx_asig_enfermera` (`enfermera_id`),
|
||||
KEY `idx_asig_estado` (`estado`),
|
||||
KEY `idx_asig_asignada` (`asignada_por`),
|
||||
CONSTRAINT `fk_asig_domicilio` FOREIGN KEY (`domicilio_id`)
|
||||
REFERENCES `lab_domicilios` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_asig_enfermera` FOREIGN KEY (`enfermera_id`)
|
||||
REFERENCES `lab_enfermeras` (`id`) ON DELETE RESTRICT,
|
||||
CONSTRAINT `fk_asig_admin` FOREIGN KEY (`asignada_por`)
|
||||
REFERENCES `admin_users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Asignación de enfermeras a domicilios';
|
||||
@@ -0,0 +1,34 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260302_lab_06_autorizaciones
|
||||
-- Módulo Administrativo: Log de cambios de estado de órdenes
|
||||
-- Cada cambio de estado genera un registro aquí (trazabilidad)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lab_autorizaciones` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`orden_id` INT(11) NOT NULL COMMENT 'FK a lab_ordenes_medicas.id',
|
||||
`accion` ENUM(
|
||||
'creada',
|
||||
'en_revision',
|
||||
'autorizada',
|
||||
'rechazada',
|
||||
'en_domicilio',
|
||||
'completada',
|
||||
'editada'
|
||||
) NOT NULL,
|
||||
`estado_anterior` VARCHAR(50) DEFAULT NULL,
|
||||
`estado_nuevo` VARCHAR(50) DEFAULT NULL,
|
||||
`realizada_por` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id',
|
||||
`comentario` TEXT DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aut_orden` (`orden_id`),
|
||||
KEY `idx_aut_admin` (`realizada_por`),
|
||||
KEY `idx_aut_accion` (`accion`),
|
||||
KEY `idx_aut_fecha` (`created_at`),
|
||||
CONSTRAINT `fk_aut_orden` FOREIGN KEY (`orden_id`)
|
||||
REFERENCES `lab_ordenes_medicas` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_aut_admin` FOREIGN KEY (`realizada_por`)
|
||||
REFERENCES `admin_users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Historial de cambios de estado de órdenes médicas';
|
||||
@@ -0,0 +1,27 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260302_lab_07_actividad_admin
|
||||
-- Módulo Administrativo: Trazabilidad general del módulo
|
||||
-- Registra TODA acción realizada por administradoras
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lab_actividad_admin` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`admin_id` INT(11) DEFAULT NULL COMMENT 'FK a admin_users.id',
|
||||
`admin_nombre` VARCHAR(150) DEFAULT NULL COMMENT 'Snapshot del nombre (por si se elimina)',
|
||||
`modulo` VARCHAR(50) NOT NULL COMMENT 'pacientes|ordenes|domicilios|enfermeras|asignaciones',
|
||||
`accion` VARCHAR(100) NOT NULL COMMENT 'crear|editar|eliminar|autorizar|rechazar|asignar...',
|
||||
`entidad_id` INT(11) DEFAULT NULL COMMENT 'ID del registro afectado',
|
||||
`detalle` TEXT DEFAULT NULL COMMENT 'JSON o descripción del cambio',
|
||||
`ip_address` VARCHAR(45) DEFAULT NULL,
|
||||
`user_agent` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_act_admin` (`admin_id`),
|
||||
KEY `idx_act_modulo` (`modulo`),
|
||||
KEY `idx_act_accion` (`accion`),
|
||||
KEY `idx_act_entidad` (`entidad_id`),
|
||||
KEY `idx_act_fecha` (`created_at`),
|
||||
CONSTRAINT `fk_act_admin` FOREIGN KEY (`admin_id`)
|
||||
REFERENCES `admin_users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Log de trazabilidad del módulo administrativo';
|
||||
@@ -0,0 +1,47 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260302_lab_08_roles_enfermeros
|
||||
-- Módulo Laboratorio: Rol enfermero + servicios extra
|
||||
-- ============================================================
|
||||
|
||||
-- 1. Agregar columnas role y enfermera_id a admin_users
|
||||
ALTER TABLE `admin_users`
|
||||
ADD COLUMN IF NOT EXISTS `role` ENUM('admin','enfermero') NOT NULL DEFAULT 'admin'
|
||||
COMMENT 'Rol del usuario: admin = acceso total, enfermero = solo portal propio',
|
||||
ADD COLUMN IF NOT EXISTS `enfermera_id` INT(11) NULL DEFAULT NULL
|
||||
COMMENT 'Vincula el usuario con su registro en lab_enfermeras';
|
||||
|
||||
-- Índice para lookups rápidos por rol
|
||||
ALTER TABLE `admin_users`
|
||||
ADD INDEX IF NOT EXISTS `idx_role` (`role`),
|
||||
ADD INDEX IF NOT EXISTS `idx_enf_id` (`enfermera_id`);
|
||||
|
||||
-- 2. Tabla de servicios extra (sin orden médica)
|
||||
-- El enfermero puede agregar servicios adicionales durante la visita
|
||||
CREATE TABLE IF NOT EXISTS `lab_servicios_extra` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`domicilio_id` INT(11) NOT NULL,
|
||||
`descripcion` VARCHAR(255) NOT NULL COMMENT 'Ej: Inyección, Cura, Nebulización',
|
||||
`tipo` ENUM(
|
||||
'inyeccion',
|
||||
'cura',
|
||||
'nebulizacion',
|
||||
'toma_muestra',
|
||||
'tension_arterial',
|
||||
'glucometria',
|
||||
'otro'
|
||||
) NOT NULL DEFAULT 'otro',
|
||||
`notas` TEXT DEFAULT NULL,
|
||||
`requiere_pago` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`valor` DECIMAL(10,2) DEFAULT NULL,
|
||||
`realizado_por` INT(11) DEFAULT NULL COMMENT 'ID de lab_enfermeras',
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_se_domicilio` (`domicilio_id`),
|
||||
KEY `idx_se_enfermera` (`realizado_por`),
|
||||
CONSTRAINT `fk_se_domicilio` FOREIGN KEY (`domicilio_id`)
|
||||
REFERENCES `lab_domicilios` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_se_enfermera` FOREIGN KEY (`realizado_por`)
|
||||
REFERENCES `lab_enfermeras` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Servicios adicionales agregados por enfermeros durante visitas';
|
||||
@@ -0,0 +1,63 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260302_lab_09_formularios
|
||||
-- Módulo Formularios: creador drag&drop, envío a clientes, firma
|
||||
-- ============================================================
|
||||
|
||||
-- 1. Plantillas de formularios (diseñadas por admins)
|
||||
CREATE TABLE IF NOT EXISTS `lab_formularios` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`nombre` VARCHAR(150) NOT NULL,
|
||||
`descripcion` TEXT DEFAULT NULL,
|
||||
`categoria` ENUM(
|
||||
'consentimiento',
|
||||
'historia_clinica',
|
||||
'autorizacion',
|
||||
'encuesta',
|
||||
'otro'
|
||||
) NOT NULL DEFAULT 'otro',
|
||||
`esquema` LONGTEXT NOT NULL COMMENT 'JSON con el array de campos del formulario',
|
||||
`permite_firma` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`requiere_firma` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`version` SMALLINT NOT NULL DEFAULT 1,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`creado_por` INT(11) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_form_activo` (`is_active`),
|
||||
KEY `idx_form_categ` (`categoria`),
|
||||
CONSTRAINT `fk_form_admin` FOREIGN KEY (`creado_por`)
|
||||
REFERENCES `admin_users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Plantillas de formularios creadas por administradores';
|
||||
|
||||
-- 2. Envíos (instancias de un formulario enviado a un paciente)
|
||||
CREATE TABLE IF NOT EXISTS `lab_form_envios` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`formulario_id` INT(11) NOT NULL,
|
||||
`paciente_id` INT(11) DEFAULT NULL,
|
||||
`domicilio_id` INT(11) DEFAULT NULL,
|
||||
`token` CHAR(64) NOT NULL UNIQUE COMMENT 'Token público único para acceso del cliente',
|
||||
`datos_prefilled` LONGTEXT DEFAULT NULL COMMENT 'JSON con datos pre-llenados por el enfermero',
|
||||
`datos_cliente` LONGTEXT DEFAULT NULL COMMENT 'JSON con respuestas del cliente',
|
||||
`firma_svg` LONGTEXT DEFAULT NULL COMMENT 'Firma del cliente como SVG/PNG base64',
|
||||
`ip_cliente` VARCHAR(45) DEFAULT NULL,
|
||||
`user_agent` VARCHAR(512) DEFAULT NULL,
|
||||
`estado` ENUM('pendiente','completado','firmado','expirado') NOT NULL DEFAULT 'pendiente',
|
||||
`enviado_por` INT(11) DEFAULT NULL COMMENT 'ID admin_users que envió el link',
|
||||
`enviado_via` ENUM('whatsapp','email','link') DEFAULT 'whatsapp',
|
||||
`expira_en` DATETIME DEFAULT NULL,
|
||||
`completado_en` DATETIME DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_fenv_form` (`formulario_id`),
|
||||
KEY `idx_fenv_paciente` (`paciente_id`),
|
||||
KEY `idx_fenv_token` (`token`),
|
||||
KEY `idx_fenv_estado` (`estado`),
|
||||
CONSTRAINT `fk_fenv_form` FOREIGN KEY (`formulario_id`) REFERENCES `lab_formularios` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_fenv_paciente` FOREIGN KEY (`paciente_id`) REFERENCES `lab_pacientes` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_fenv_domicilio` FOREIGN KEY (`domicilio_id`) REFERENCES `lab_domicilios` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_fenv_enviado` FOREIGN KEY (`enviado_por`) REFERENCES `admin_users` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Instancias de formularios enviados a pacientes';
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
/**
|
||||
* Migración: Módulo Administrativo de Laboratorio
|
||||
* Ejecuta las 7 migraciones SQL del módulo en orden
|
||||
*
|
||||
* Uso:
|
||||
* php migrations/20260302_lab_run_migrations.php
|
||||
* php migrations/20260302_lab_run_migrations.php --rollback
|
||||
*
|
||||
* Las tablas se crean en este orden para respetar las FK:
|
||||
* 1. lab_pacientes (depende de users)
|
||||
* 2. lab_enfermeras (independiente)
|
||||
* 3. lab_ordenes_medicas (depende de lab_pacientes, conversations, admin_users)
|
||||
* 4. lab_domicilios (depende de lab_ordenes_medicas, lab_pacientes)
|
||||
* 5. lab_asignaciones (depende de lab_domicilios, lab_enfermeras)
|
||||
* 6. lab_autorizaciones (depende de lab_ordenes_medicas)
|
||||
* 7. lab_actividad_admin (depende de admin_users)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
$isRollback = in_array('--rollback', $argv ?? []);
|
||||
$line = str_repeat('─', 60);
|
||||
|
||||
echo "\n$line\n";
|
||||
echo $isRollback
|
||||
? " 🔄 ROLLBACK — Módulo Administrativo Lab\n"
|
||||
: " 🚀 MIGRACIÓN — Módulo Administrativo Lab\n";
|
||||
echo "$line\n\n";
|
||||
|
||||
// ── Archivos en orden de ejecución (rollback = inverso) ──────────────────────
|
||||
$migrations = [
|
||||
'20260302_lab_01_pacientes.sql',
|
||||
'20260302_lab_02_enfermeras.sql',
|
||||
'20260302_lab_03_ordenes_medicas.sql',
|
||||
'20260302_lab_04_domicilios.sql',
|
||||
'20260302_lab_05_asignaciones.sql',
|
||||
'20260302_lab_06_autorizaciones.sql',
|
||||
'20260302_lab_07_actividad_admin.sql',
|
||||
];
|
||||
|
||||
// ── Sentencias DROP para rollback (orden inverso para respetar FK) ───────────
|
||||
$rollback = [
|
||||
'DROP TABLE IF EXISTS `lab_actividad_admin`',
|
||||
'DROP TABLE IF EXISTS `lab_autorizaciones`',
|
||||
'DROP TABLE IF EXISTS `lab_asignaciones`',
|
||||
'DROP TABLE IF EXISTS `lab_domicilios`',
|
||||
'DROP TABLE IF EXISTS `lab_ordenes_medicas`',
|
||||
'DROP TABLE IF EXISTS `lab_enfermeras`',
|
||||
'DROP TABLE IF EXISTS `lab_pacientes`',
|
||||
];
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
if ($isRollback) {
|
||||
// ── ROLLBACK ─────────────────────────────────────────────────────────
|
||||
foreach ($rollback as $sql) {
|
||||
$table = preg_replace('/.*`(lab_\w+)`.*/', '$1', $sql);
|
||||
$pdo->exec($sql);
|
||||
echo " ✅ DROP $table\n";
|
||||
}
|
||||
echo "\n ✔ Rollback completado correctamente.\n\n";
|
||||
} else {
|
||||
// ── MIGRACIÓN ────────────────────────────────────────────────────────
|
||||
$dir = __DIR__;
|
||||
|
||||
foreach ($migrations as $file) {
|
||||
$path = "$dir/$file";
|
||||
|
||||
if (!file_exists($path)) {
|
||||
echo " ⚠️ Archivo no encontrado: $file (omitido)\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = file_get_contents($path);
|
||||
|
||||
// Separar sentencias por ';' pero solo las CREATE TABLE
|
||||
// (Los archivos contienen una sola sentencia CREATE TABLE)
|
||||
$pdo->exec($sql);
|
||||
|
||||
// Extraer nombre de tabla del archivo para feedback
|
||||
preg_match('/CREATE TABLE IF NOT EXISTS `(\w+)`/i', $sql, $m);
|
||||
$table = $m[1] ?? $file;
|
||||
echo " ✅ $table\n";
|
||||
}
|
||||
|
||||
echo "\n$line\n";
|
||||
echo " ✔ Todas las migraciones ejecutadas correctamente.\n";
|
||||
echo "$line\n\n";
|
||||
|
||||
// ── Tabla de control de migraciones ejecutadas en system_logs ────────
|
||||
try {
|
||||
$pdo->prepare("
|
||||
INSERT INTO system_logs (level, message, context, created_at)
|
||||
VALUES ('info', 'lab_module_migration', ?, NOW())
|
||||
")->execute([json_encode([
|
||||
'version' => '20260302',
|
||||
'module' => 'lab_admin',
|
||||
'tables' => 7,
|
||||
'executed' => date('Y-m-d H:i:s'),
|
||||
])]);
|
||||
} catch (Exception $e) {
|
||||
// system_logs puede no tener estas columnas — no falla la migración
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo "\n ❌ ERROR: " . $e->getMessage() . "\n\n";
|
||||
echo " Ejecuta --rollback para deshacer los cambios parciales.\n\n";
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
-- ============================================================
|
||||
-- Migration: 20260303_lab_10_domicilio_extras
|
||||
-- Agrega tipo_cliente (particular/seguro) y examenes_solicitados
|
||||
-- a la tabla lab_domicilios, para cuando no hay orden médica.
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `lab_domicilios`
|
||||
ADD COLUMN `tipo_cliente` ENUM('particular','seguro','eps') NOT NULL DEFAULT 'particular'
|
||||
COMMENT 'Si el servicio es particular, por seguro o EPS'
|
||||
AFTER `tipo_servicio`,
|
||||
|
||||
ADD COLUMN `examenes_solicitados` TEXT DEFAULT NULL
|
||||
COMMENT 'Exámenes escritos (cuando no hay foto de orden médica)'
|
||||
AFTER `tipo_cliente`;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Migración: agregar columnas de contenido a message_templates (producción)
|
||||
-- Las columnas body_text, header_*, footer_text, components, example_parameters
|
||||
-- existen en el schema local pero faltaban en producción.
|
||||
|
||||
ALTER TABLE `message_templates`
|
||||
ADD COLUMN IF NOT EXISTS `body_text` TEXT DEFAULT NULL
|
||||
COMMENT 'Texto del cuerpo de la plantilla'
|
||||
AFTER `status`,
|
||||
ADD COLUMN IF NOT EXISTS `header_text` VARCHAR(255) DEFAULT NULL
|
||||
COMMENT 'Texto del encabezado (si header_type=text)'
|
||||
AFTER `body_text`,
|
||||
ADD COLUMN IF NOT EXISTS `header_type` ENUM('text','image','video','document') DEFAULT NULL
|
||||
COMMENT 'Tipo de encabezado multimedia'
|
||||
AFTER `header_text`,
|
||||
ADD COLUMN IF NOT EXISTS `footer_text` VARCHAR(255) DEFAULT NULL
|
||||
COMMENT 'Texto del pie de la plantilla'
|
||||
AFTER `header_type`,
|
||||
ADD COLUMN IF NOT EXISTS `components` LONGTEXT DEFAULT NULL
|
||||
COMMENT 'JSON completo de componentes de la plantilla'
|
||||
AFTER `footer_text`,
|
||||
ADD COLUMN IF NOT EXISTS `example_parameters` LONGTEXT DEFAULT NULL
|
||||
COMMENT 'JSON con parámetros de ejemplo'
|
||||
AFTER `components`,
|
||||
ADD COLUMN IF NOT EXISTS `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
AFTER `created_at`;
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
$db = Database::getInstance();
|
||||
|
||||
// ── 1. Tabla lab_config ───────────────────────────────────────────────
|
||||
$db->query('CREATE TABLE IF NOT EXISTS lab_config (
|
||||
clave VARCHAR(80) NOT NULL PRIMARY KEY,
|
||||
valor LONGTEXT NOT NULL DEFAULT "",
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||||
echo "tabla lab_config OK\n";
|
||||
|
||||
// Valores iniciales
|
||||
$defaults = [
|
||||
['empresa_nombre', 'Laboratorio Clínico Ximena'],
|
||||
['empresa_subtitulo', 'Análisis Clínicos'],
|
||||
['empresa_direccion', ''],
|
||||
['empresa_telefono', ''],
|
||||
['empresa_email', ''],
|
||||
['empresa_ciudad', ''],
|
||||
['doc_color', '#1565c0'],
|
||||
['doc_logo_base64', ''],
|
||||
['doc_pie_pagina', 'Documento generado digitalmente. Verifique su autenticidad en el sistema.'],
|
||||
];
|
||||
foreach ($defaults as [$k, $v]) {
|
||||
$db->query('INSERT IGNORE INTO lab_config(clave, valor) VALUES(?, ?)', [$k, $v]);
|
||||
}
|
||||
echo count($defaults) . " valores iniciales insertados (IGNORE)\n";
|
||||
|
||||
// ── 2. Columnas nuevas en lab_formularios ─────────────────────────────
|
||||
$cols = [
|
||||
'doc_encabezado VARCHAR(200) NULL DEFAULT NULL',
|
||||
'doc_subtitulo VARCHAR(200) NULL DEFAULT NULL',
|
||||
'doc_logo_base64 LONGTEXT NULL DEFAULT NULL',
|
||||
'doc_color VARCHAR(20) NULL DEFAULT NULL',
|
||||
'doc_pie_pagina VARCHAR(500) NULL DEFAULT NULL',
|
||||
];
|
||||
|
||||
foreach ($cols as $def) {
|
||||
$name = explode(' ', trim($def))[0];
|
||||
$exists = $db->fetchAll("SHOW COLUMNS FROM lab_formularios WHERE Field = '$name'");
|
||||
if (!$exists) {
|
||||
$db->query("ALTER TABLE lab_formularios ADD COLUMN $def");
|
||||
echo "Columna agregada: $name\n";
|
||||
} else {
|
||||
echo "Ya existía: $name\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\nMigración completada OK\n";
|
||||
@@ -0,0 +1,75 @@
|
||||
-- ============================================================
|
||||
-- Migración: Sistema de Roles y Módulos
|
||||
-- Fecha: 2026-03-04
|
||||
-- Descripción: Crea tablas roles y role_modules; vincula admin_users
|
||||
-- ============================================================
|
||||
|
||||
-- ── 1. Tabla de roles ────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS `roles` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL COMMENT 'Nombre legible del rol',
|
||||
`slug` VARCHAR(50) NOT NULL COMMENT 'Clave interna (enfermero, admin, …)',
|
||||
`description` TEXT DEFAULT NULL,
|
||||
`color` VARCHAR(20) DEFAULT '#6c757d' COMMENT 'Color HEX para la UI',
|
||||
`is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = no se puede eliminar',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_slug` (`slug`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ── 2. Tabla de módulos asignados por rol ────────────────────
|
||||
-- Cada fila «activa» el módulo para ese rol.
|
||||
CREATE TABLE IF NOT EXISTS `role_modules` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`role_id` INT(11) NOT NULL,
|
||||
`module_slug` VARCHAR(100) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_role_module` (`role_id`, `module_slug`),
|
||||
CONSTRAINT `fk_rm_role` FOREIGN KEY (`role_id`)
|
||||
REFERENCES `roles`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ── 3. Vincular admin_users con roles ────────────────────────
|
||||
-- (Se ignora si la columna ya existe - compatible MySQL y MariaDB)
|
||||
ALTER TABLE `admin_users`
|
||||
ADD COLUMN `role_id` INT(11) NULL DEFAULT NULL
|
||||
COMMENT 'FK a roles.id';
|
||||
|
||||
ALTER TABLE `admin_users`
|
||||
ADD INDEX `idx_role_id` (`role_id`);
|
||||
|
||||
-- ── 4. Roles del sistema (no borrables) ─────────────────────
|
||||
INSERT IGNORE INTO `roles` (`id`, `name`, `slug`, `description`, `color`, `is_system`) VALUES
|
||||
(1, 'Administrador', 'admin', 'Acceso completo a todos los módulos', '#0d6efd', 1),
|
||||
(2, 'Enfermero', 'enfermero', 'Solo portal de domicilios asignados y formularios','#6f42c1', 1);
|
||||
|
||||
-- ── 5. Módulos para el rol Administrador (todos) ─────────────
|
||||
INSERT IGNORE INTO `role_modules` (`role_id`, `module_slug`) VALUES
|
||||
(1, 'whatsapp'),
|
||||
(1, 'lab_dashboard'),
|
||||
(1, 'lab_ordenes'),
|
||||
(1, 'lab_pacientes'),
|
||||
(1, 'lab_domicilios'),
|
||||
(1, 'lab_enfermeras'),
|
||||
(1, 'lab_formularios'),
|
||||
(1, 'lab_reportes'),
|
||||
(1, 'lab_configuracion'),
|
||||
(1, 'usuarios');
|
||||
|
||||
-- ── 6. Módulos para el rol Enfermero ────────────────────────
|
||||
INSERT IGNORE INTO `role_modules` (`role_id`, `module_slug`) VALUES
|
||||
(2, 'enfermero_portal'),
|
||||
(2, 'lab_formularios');
|
||||
|
||||
-- ── 7. Vincular usuarios existentes con su role_id ───────────
|
||||
UPDATE `admin_users` SET `role_id` = 1 WHERE `role` = 'admin' AND `role_id` IS NULL;
|
||||
UPDATE `admin_users` SET `role_id` = 2 WHERE `role` = 'enfermero' AND `role_id` IS NULL;
|
||||
-- Si algún usuario no tiene rol asignado, asignarle admin por defecto
|
||||
UPDATE `admin_users` SET `role_id` = 1 WHERE `role_id` IS NULL;
|
||||
|
||||
-- ── 8. FK suave (solo si FK no existe aún) ──────────────────
|
||||
-- (MariaDB/MySQL no tiene ADD CONSTRAINT IF NOT EXISTS, usamos el IGNORE del INSERT)
|
||||
-- La FK se omite intencionalmente para no bloquear si ya existe;
|
||||
-- conéctala manualmente si el motor lo permite:
|
||||
-- ALTER TABLE admin_users ADD CONSTRAINT fk_au_role FOREIGN KEY (role_id) REFERENCES roles(id);
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/**
|
||||
* Ejecuta la migración del sistema de Roles y Módulos.
|
||||
* Acceder UNA sola vez desde el navegador (admin autenticado).
|
||||
* Eliminar o proteger este archivo después de ejecutar.
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) {
|
||||
header('Location: login.php'); exit;
|
||||
}
|
||||
|
||||
$sqlFile = __DIR__ . '/migrations/20260304_roles_and_modules.sql';
|
||||
if (!file_exists($sqlFile)) {
|
||||
die('❌ Archivo de migración no encontrado: ' . $sqlFile);
|
||||
}
|
||||
|
||||
$sql = file_get_contents($sqlFile);
|
||||
|
||||
// Eliminar comentarios de línea (-- ...) y comentarios de bloque (/* ... */)
|
||||
$sql = preg_replace('/--[^\n]*/', '', $sql);
|
||||
$sql = preg_replace('/\/\*.*?\*\//s', '', $sql);
|
||||
|
||||
// Dividir en sentencias individuales por el punto y coma
|
||||
$statements = array_filter(
|
||||
array_map('trim', explode(';', $sql)),
|
||||
fn($s) => $s !== '' && $s !== "\n"
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo = createDbConnection();
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$ok = 0; $errors = [];
|
||||
foreach ($statements as $stmt) {
|
||||
if (!trim($stmt)) continue;
|
||||
try {
|
||||
$pdo->exec($stmt);
|
||||
$ok++;
|
||||
} catch (PDOException $e) {
|
||||
// Ignorar: columna ya existe, índice ya existe, clave duplicada en INSERT
|
||||
$msg = $e->getMessage();
|
||||
if (
|
||||
str_contains($msg, 'Duplicate entry') ||
|
||||
str_contains($msg, 'Duplicate key') ||
|
||||
str_contains($msg, 'already exists') ||
|
||||
str_contains($msg, 'Duplicate column name') ||
|
||||
str_contains($msg, "Can't DROP") ||
|
||||
preg_match('/already exists/i', $msg)
|
||||
) {
|
||||
$ok++;
|
||||
} else {
|
||||
$errors[] = '<strong>Error:</strong> ' . htmlspecialchars($msg) .
|
||||
'<br><code>' . htmlspecialchars(substr($stmt, 0, 200)) . '</code>';
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8">
|
||||
<title>Migración Roles</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head><body class="p-4">
|
||||
<div class="container" style="max-width:740px">
|
||||
<h3 class="mb-3">🔧 Migración: Roles y Módulos</h3>
|
||||
<div class="alert alert-<?= $errors ? 'warning' : 'success' ?>">
|
||||
✅ <strong><?= $ok ?></strong> sentencias ejecutadas correctamente.<br>
|
||||
<?php if ($errors): ?>
|
||||
⚠️ <strong><?= count($errors) ?></strong> advertencias (ver abajo).
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php foreach ($errors as $e): ?>
|
||||
<div class="alert alert-danger small"><?= $e ?></div>
|
||||
<?php endforeach; ?>
|
||||
<a href="lab_usuarios.php" class="btn btn-primary">Ir a Usuarios & Roles →</a>
|
||||
<a href="lab_dashboard.php" class="btn btn-outline-secondary ms-2">Dashboard</a>
|
||||
<p class="mt-3 text-muted small">⚠️ Elimina o protege este archivo (<code>run_roles_migration.php</code>) después de ejecutarlo.</p>
|
||||
</div></body></html>
|
||||
<?php
|
||||
} catch (Exception $e) {
|
||||
echo '<div class="alert alert-danger p-3">❌ Error de conexión: ' . htmlspecialchars($e->getMessage()) . '</div>';
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Genera cronograma.xlsx — Cronograma de Ejecución del Proyecto
|
||||
Laboratorio Ximena / WhatsApp Bot Manager
|
||||
Inicio: 2 de marzo de 2026
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import (PatternFill, Font, Alignment, Border, Side,
|
||||
GradientFill)
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
# ─── CONFIGURACIÓN DEL PROYECTO ─────────────────────────────────────────────
|
||||
|
||||
INICIO = date(2026, 3, 2)
|
||||
|
||||
FASES = [
|
||||
# (fase, tarea, inicio_offset_dias, duracion_dias, estado)
|
||||
# Estado: 'done' | 'active' | 'pending'
|
||||
("Fase 1 – Sistema de Roles", "Diseño BD (roles, modules)", 0, 4, "done"),
|
||||
("Fase 1 – Sistema de Roles", "APIs roles y usuarios", 4, 4, "done"),
|
||||
("Fase 1 – Sistema de Roles", "UI lab_usuarios.php", 6, 4, "done"),
|
||||
("Fase 1 – Sistema de Roles", "Unificación panel admin", 8, 3, "done"),
|
||||
|
||||
("Fase 2 – Portal Enfermero", "Corrección login enfermero", 11, 2, "done"),
|
||||
("Fase 2 – Portal Enfermero", "Fix SyntaxError botón formulario", 11, 2, "done"),
|
||||
("Fase 2 – Portal Enfermero", "Submit idempotente (ya completado)", 13, 2, "done"),
|
||||
("Fase 2 – Portal Enfermero", "Firma digital — canvas", 15, 3, "done"),
|
||||
("Fase 2 – Portal Enfermero", "Firma por foto (opcional)", 18, 2, "active"),
|
||||
|
||||
("Fase 3 – Formularios Avanz.", "Campos dinámicos tipo fecha/selector", 21, 4, "pending"),
|
||||
("Fase 3 – Formularios Avanz.", "Historial de envíos por paciente", 25, 3, "pending"),
|
||||
("Fase 3 – Formularios Avanz.", "Descarga PDF de formulario enviado", 25, 4, "pending"),
|
||||
|
||||
("Fase 4 – Agendamiento", "Calendario de domicilios", 32, 5, "pending"),
|
||||
("Fase 4 – Agendamiento", "Mensajes programados WhatsApp", 36, 4, "pending"),
|
||||
("Fase 4 – Agendamiento", "Recordatorios automáticos", 38, 3, "pending"),
|
||||
|
||||
("Fase 5 – Reportes", "Dashboard métricas laboratorio", 44, 4, "pending"),
|
||||
("Fase 5 – Reportes", "Exportación Excel / CSV", 46, 3, "pending"),
|
||||
("Fase 5 – Reportes", "Reportes por enfermera", 48, 3, "pending"),
|
||||
|
||||
("Fase 6 – QA y Despliegue", "Pruebas integrales", 53, 4, "pending"),
|
||||
("Fase 6 – QA y Despliegue", "Corrección de bugs", 55, 3, "pending"),
|
||||
("Fase 6 – QA y Despliegue", "Despliegue en producción", 58, 3, "pending"),
|
||||
("Fase 6 – QA y Despliegue", "Capacitación y entrega", 60, 2, "pending"),
|
||||
]
|
||||
|
||||
# ─── COLORES ────────────────────────────────────────────────────────────────
|
||||
|
||||
COLORES_FASE = {
|
||||
"Fase 1 – Sistema de Roles": {"header": "1565C0", "barra_done": "42A5F5", "barra_active": "1E88E5", "barra_pending": "90CAF9"},
|
||||
"Fase 2 – Portal Enfermero": {"header": "2E7D32", "barra_done": "66BB6A", "barra_active": "43A047", "barra_pending": "A5D6A7"},
|
||||
"Fase 3 – Formularios Avanz.": {"header": "6A1B9A", "barra_done": "AB47BC", "barra_active": "8E24AA", "barra_pending": "CE93D8"},
|
||||
"Fase 4 – Agendamiento": {"header": "E65100", "barra_done": "FFA726", "barra_active": "FB8C00", "barra_pending": "FFCC80"},
|
||||
"Fase 5 – Reportes": {"header": "00695C", "barra_done": "26A69A", "barra_active": "00897B", "barra_pending": "80CBC4"},
|
||||
"Fase 6 – QA y Despliegue": {"header": "B71C1C", "barra_done": "EF5350", "barra_active": "E53935", "barra_pending": "EF9A9A"},
|
||||
}
|
||||
|
||||
ESTADO_LABEL = {"done": "✅ Completado", "active": "🔄 En curso", "pending": "⏳ Pendiente"}
|
||||
ESTADO_FILL = {
|
||||
"done": "D0F0C0",
|
||||
"active": "FFF9C4",
|
||||
"pending": "F5F5F5",
|
||||
}
|
||||
|
||||
# ─── HELPERS ────────────────────────────────────────────────────────────────
|
||||
|
||||
def fecha(offset): return INICIO + timedelta(days=offset)
|
||||
def col_for_date(d, week_cols_start, all_weeks):
|
||||
"""Devuelve columna Excel (1-based) para una fecha dada."""
|
||||
for i, w in enumerate(all_weeks):
|
||||
if w <= d < w + timedelta(days=7):
|
||||
return week_cols_start + i
|
||||
return None
|
||||
|
||||
def thin():
|
||||
s = Side(style='thin', color='CCCCCC')
|
||||
return Border(left=s, right=s, top=s, bottom=s)
|
||||
|
||||
def fill(hex_color):
|
||||
return PatternFill("solid", fgColor=hex_color)
|
||||
|
||||
# ─── GENERAR SEMANAS ────────────────────────────────────────────────────────
|
||||
|
||||
max_offset = max(t[3] + t[2] for t in FASES)
|
||||
num_weeks = (max_offset // 7) + 2
|
||||
all_weeks = [INICIO + timedelta(weeks=i) for i in range(num_weeks)]
|
||||
|
||||
# ─── WORKBOOK ───────────────────────────────────────────────────────────────
|
||||
|
||||
wb = Workbook()
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# HOJA 1 — GANTT
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
ws = wb.active
|
||||
ws.title = "Gantt"
|
||||
|
||||
TASK_COLS = 4 # Fase | Tarea | Inicio | Estado
|
||||
WEEK_COL_START = TASK_COLS + 1 # columna Excel donde empiezan las semanas
|
||||
|
||||
# ── Dimensiones ─────────────────────────────────────────────────────────────
|
||||
ws.column_dimensions['A'].width = 26
|
||||
ws.column_dimensions['B'].width = 38
|
||||
ws.column_dimensions['C'].width = 13
|
||||
ws.column_dimensions['D'].width = 16
|
||||
|
||||
for i in range(num_weeks):
|
||||
cl = get_column_letter(WEEK_COL_START + i)
|
||||
ws.column_dimensions[cl].width = 5.5
|
||||
|
||||
# ── Fila 1 — Título ─────────────────────────────────────────────────────────
|
||||
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=WEEK_COL_START + num_weeks - 1)
|
||||
ws['A1'] = "CRONOGRAMA DE EJECUCIÓN — Laboratorio Ximena"
|
||||
ws['A1'].font = Font(bold=True, size=14, color="FFFFFF")
|
||||
ws['A1'].fill = fill("0D47A1")
|
||||
ws['A1'].alignment = Alignment(horizontal="center", vertical="center")
|
||||
ws.row_dimensions[1].height = 28
|
||||
|
||||
# ── Fila 2 — Sub-título ─────────────────────────────────────────────────────
|
||||
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=WEEK_COL_START + num_weeks - 1)
|
||||
ws['A2'] = f"Inicio: {INICIO.strftime('%d de %B de %Y')} | Total estimado: {num_weeks} semanas"
|
||||
ws['A2'].font = Font(italic=True, size=10, color="444444")
|
||||
ws['A2'].fill = fill("E3F2FD")
|
||||
ws['A2'].alignment = Alignment(horizontal="center", vertical="center")
|
||||
ws.row_dimensions[2].height = 18
|
||||
|
||||
# ── Fila 3 — Encabezados columnas texto ─────────────────────────────────────
|
||||
heads = ["Fase", "Tarea", "Inicio", "Estado"]
|
||||
for c, h in enumerate(heads, 1):
|
||||
cell = ws.cell(row=3, column=c, value=h)
|
||||
cell.font = Font(bold=True, size=10, color="FFFFFF")
|
||||
cell.fill = fill("37474F")
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
cell.border = thin()
|
||||
|
||||
# ── Fila 3 — Encabezados semanas ────────────────────────────────────────────
|
||||
for i, w in enumerate(all_weeks):
|
||||
col = WEEK_COL_START + i
|
||||
cell = ws.cell(row=3, column=col, value=w.strftime("%-d/%m"))
|
||||
cell.font = Font(bold=True, size=8, color="FFFFFF")
|
||||
cell.fill = fill("546E7A")
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", text_rotation=60)
|
||||
cell.border = thin()
|
||||
ws.row_dimensions[3].height = 44
|
||||
|
||||
# ── Filas de datos ───────────────────────────────────────────────────────────
|
||||
ROW = 4
|
||||
prev_fase = None
|
||||
|
||||
for (fase, tarea, start_off, dur, estado) in FASES:
|
||||
colores = COLORES_FASE[fase]
|
||||
|
||||
# Separador de fase (cuando cambia)
|
||||
if fase != prev_fase:
|
||||
ws.merge_cells(start_row=ROW, start_column=1,
|
||||
end_row=ROW, end_column=WEEK_COL_START + num_weeks - 1)
|
||||
cell = ws.cell(row=ROW, column=1, value=f" {fase}")
|
||||
cell.font = Font(bold=True, size=10, color="FFFFFF")
|
||||
cell.fill = fill(colores["header"])
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
cell.border = thin()
|
||||
ws.row_dimensions[ROW].height = 20
|
||||
ROW += 1
|
||||
prev_fase = fase
|
||||
|
||||
# Calcular fechas
|
||||
d_ini = fecha(start_off)
|
||||
d_fin = fecha(start_off + dur - 1)
|
||||
row_fill = fill(ESTADO_FILL[estado])
|
||||
|
||||
# Columna A — Fase (vacía, ya fue el encabezado)
|
||||
c_fase = ws.cell(row=ROW, column=1, value="")
|
||||
c_fase.fill = row_fill
|
||||
c_fase.border = thin()
|
||||
|
||||
# Columna B — Tarea
|
||||
c_task = ws.cell(row=ROW, column=2, value=tarea)
|
||||
c_task.font = Font(size=9)
|
||||
c_task.fill = row_fill
|
||||
c_task.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
c_task.border = thin()
|
||||
|
||||
# Columna C — Fecha inicio
|
||||
c_date = ws.cell(row=ROW, column=3, value=d_ini.strftime("%-d %b"))
|
||||
c_date.font = Font(size=9)
|
||||
c_date.fill = row_fill
|
||||
c_date.alignment = Alignment(horizontal="center", vertical="center")
|
||||
c_date.border = thin()
|
||||
|
||||
# Columna D — Estado
|
||||
c_est = ws.cell(row=ROW, column=4, value=ESTADO_LABEL[estado])
|
||||
c_est.font = Font(size=9, bold=(estado == "active"))
|
||||
c_est.fill = row_fill
|
||||
c_est.alignment = Alignment(horizontal="center", vertical="center")
|
||||
c_est.border = thin()
|
||||
|
||||
# Barras de Gantt
|
||||
barra_color = colores[f"barra_{estado}"]
|
||||
w_ini = col_for_date(d_ini, WEEK_COL_START, all_weeks)
|
||||
w_fin = col_for_date(d_fin, WEEK_COL_START, all_weeks)
|
||||
if w_ini is None: w_ini = WEEK_COL_START
|
||||
if w_fin is None: w_fin = WEEK_COL_START + num_weeks - 1
|
||||
|
||||
for col in range(WEEK_COL_START, WEEK_COL_START + num_weeks):
|
||||
cell = ws.cell(row=ROW, column=col, value="")
|
||||
if w_ini <= col <= w_fin:
|
||||
cell.fill = fill(barra_color)
|
||||
else:
|
||||
cell.fill = fill("F5F5F5")
|
||||
cell.border = thin()
|
||||
|
||||
ws.row_dimensions[ROW].height = 18
|
||||
ROW += 1
|
||||
|
||||
# ── Leyenda ──────────────────────────────────────────────────────────────────
|
||||
ROW += 1
|
||||
leyenda = [
|
||||
("✅ Completado", "D0F0C0"),
|
||||
("🔄 En curso", "FFF9C4"),
|
||||
("⏳ Pendiente", "F5F5F5"),
|
||||
]
|
||||
ws.cell(row=ROW, column=1, value="Leyenda:").font = Font(bold=True, size=10)
|
||||
for i, (txt, color) in enumerate(leyenda, 2):
|
||||
cell = ws.cell(row=ROW, column=i, value=txt)
|
||||
cell.fill = fill(color)
|
||||
cell.border = thin()
|
||||
cell.font = Font(size=9)
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
|
||||
# Freeze panes
|
||||
ws.freeze_panes = "E4"
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# HOJA 2 — RESUMEN POR FASE
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
ws2 = wb.create_sheet("Resumen por Fase")
|
||||
ws2.column_dimensions['A'].width = 30
|
||||
ws2.column_dimensions['B'].width = 14
|
||||
ws2.column_dimensions['C'].width = 14
|
||||
ws2.column_dimensions['D'].width = 14
|
||||
ws2.column_dimensions['E'].width = 16
|
||||
ws2.column_dimensions['F'].width = 16
|
||||
|
||||
# Título
|
||||
ws2.merge_cells("A1:F1")
|
||||
ws2['A1'] = "RESUMEN POR FASE"
|
||||
ws2['A1'].font = Font(bold=True, size=13, color="FFFFFF")
|
||||
ws2['A1'].fill = fill("0D47A1")
|
||||
ws2['A1'].alignment = Alignment(horizontal="center", vertical="center")
|
||||
ws2.row_dimensions[1].height = 26
|
||||
|
||||
# Encabezados
|
||||
enc2 = ["Fase", "Inicio", "Fin", "Duración (días)", "Tareas", "Estado"]
|
||||
for c, h in enumerate(enc2, 1):
|
||||
cell = ws2.cell(row=2, column=c, value=h)
|
||||
cell.font = Font(bold=True, size=10, color="FFFFFF")
|
||||
cell.fill = fill("37474F")
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
cell.border = thin()
|
||||
ws2.row_dimensions[2].height = 22
|
||||
|
||||
# Agrupar por fase
|
||||
from collections import defaultdict
|
||||
fases_data = defaultdict(list)
|
||||
for row in FASES:
|
||||
fases_data[row[0]].append(row)
|
||||
|
||||
ROW2 = 3
|
||||
for fase_name, tareas in fases_data.items():
|
||||
ini_fase = fecha(min(t[2] for t in tareas))
|
||||
fin_fase = fecha(max(t[2] + t[3] - 1 for t in tareas))
|
||||
dur_total = (fin_fase - ini_fase).days + 1
|
||||
n_tareas = len(tareas)
|
||||
estados = [t[4] for t in tareas]
|
||||
if all(e == "done" for e in estados):
|
||||
estado_fase = "✅ Completada"
|
||||
fc = "D0F0C0"
|
||||
elif any(e == "active" for e in estados):
|
||||
estado_fase = "🔄 En curso"
|
||||
fc = "FFF9C4"
|
||||
else:
|
||||
estado_fase = "⏳ Pendiente"
|
||||
fc = "F5F5F5"
|
||||
|
||||
colores = COLORES_FASE[fase_name]
|
||||
fondo = fill(colores["header"] if estado_fase == "✅ Completada" else fc)
|
||||
|
||||
data = [fase_name, ini_fase.strftime("%-d %b %Y"), fin_fase.strftime("%-d %b %Y"),
|
||||
dur_total, n_tareas, estado_fase]
|
||||
for c, v in enumerate(data, 1):
|
||||
cell = ws2.cell(row=ROW2, column=c, value=v)
|
||||
cell.alignment = Alignment(horizontal="center" if c > 1 else "left",
|
||||
vertical="center", wrap_text=True)
|
||||
cell.border = thin()
|
||||
cell.font = Font(size=10, bold=(c == 1),
|
||||
color=("FFFFFF" if estado_fase == "✅ Completada" else "222222"))
|
||||
cell.fill = PatternFill("solid", fgColor=colores["header"]) if estado_fase == "✅ Completada" \
|
||||
else fill(fc)
|
||||
ws2.row_dimensions[ROW2].height = 22
|
||||
ROW2 += 1
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# HOJA 3 — DETALLE DE TAREAS
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
ws3 = wb.create_sheet("Detalle Tareas")
|
||||
ws3.column_dimensions['A'].width = 28
|
||||
ws3.column_dimensions['B'].width = 40
|
||||
ws3.column_dimensions['C'].width = 14
|
||||
ws3.column_dimensions['D'].width = 14
|
||||
ws3.column_dimensions['E'].width = 12
|
||||
ws3.column_dimensions['F'].width = 16
|
||||
|
||||
ws3.merge_cells("A1:F1")
|
||||
ws3['A1'] = "DETALLE DE TAREAS"
|
||||
ws3['A1'].font = Font(bold=True, size=13, color="FFFFFF")
|
||||
ws3['A1'].fill = fill("0D47A1")
|
||||
ws3['A1'].alignment = Alignment(horizontal="center", vertical="center")
|
||||
ws3.row_dimensions[1].height = 26
|
||||
|
||||
enc3 = ["Fase", "Tarea", "Inicio", "Fin", "Días", "Estado"]
|
||||
for c, h in enumerate(enc3, 1):
|
||||
cell = ws3.cell(row=2, column=c, value=h)
|
||||
cell.font = Font(bold=True, size=10, color="FFFFFF")
|
||||
cell.fill = fill("37474F")
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
cell.border = thin()
|
||||
ws3.row_dimensions[2].height = 22
|
||||
|
||||
ROW3 = 3
|
||||
for (fase, tarea, start_off, dur, estado) in FASES:
|
||||
d_ini = fecha(start_off)
|
||||
d_fin = fecha(start_off + dur - 1)
|
||||
colores = COLORES_FASE[fase]
|
||||
fc = ESTADO_FILL[estado]
|
||||
|
||||
data = [fase, tarea, d_ini.strftime("%-d %b %Y"), d_fin.strftime("%-d %b %Y"),
|
||||
dur, ESTADO_LABEL[estado]]
|
||||
for c, v in enumerate(data, 1):
|
||||
cell = ws3.cell(row=ROW3, column=c, value=v)
|
||||
cell.alignment = Alignment(horizontal="center" if c > 2 else "left",
|
||||
vertical="center", wrap_text=True)
|
||||
cell.border = thin()
|
||||
cell.font = Font(size=9)
|
||||
cell.fill = fill(fc)
|
||||
ws3.row_dimensions[ROW3].height = 18
|
||||
ROW3 += 1
|
||||
|
||||
# ─── GUARDAR ─────────────────────────────────────────────────────────────────
|
||||
out = "/Users/lizandro/Documents/GitHub/whatsapp/cronograma.xlsx"
|
||||
wb.save(out)
|
||||
print(f"✅ Archivo generado: {out}")
|
||||
@@ -62,6 +62,21 @@ class BotService {
|
||||
error_log('[BotService] maybeResetConversationAfterIdle threw: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// ── Flujo de Términos y Condiciones ──────────────────────────────────
|
||||
// 1. Si el usuario está esperando respuesta de términos, procesarla primero.
|
||||
if (!empty($user['terms_pending'])) {
|
||||
if ($this->processTermsResponse($user, $phoneNumber, $messageText)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si el usuario necesita aceptar (nuevo, expirado, o versión nueva con forzar_reenvio).
|
||||
if ($this->needsTermsAcceptance($user)) {
|
||||
$this->sendTermsMessage($user, $phoneNumber);
|
||||
return;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Verificar si es un nuevo usuario (enviar mensaje de bienvenida)
|
||||
if ($this->isNewUser($user['id'])) {
|
||||
$this->sendWelcomeMessage($phoneNumber);
|
||||
@@ -427,6 +442,196 @@ class BotService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TÉRMINOS Y CONDICIONES
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Obtiene la versión activa de términos o null si no existe ninguna.
|
||||
*/
|
||||
private function getActiveTermsVersion(): ?array {
|
||||
try {
|
||||
return $this->db->fetch(
|
||||
"SELECT * FROM terms_versions WHERE activa = 1 ORDER BY id DESC LIMIT 1"
|
||||
) ?: null;
|
||||
} catch (Exception $e) {
|
||||
error_log('[BotService] getActiveTermsVersion error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide si el usuario debe aceptar (o re-aceptar) los términos.
|
||||
* - No hay aceptación previa (terms_accepted_at IS NULL)
|
||||
* - Han pasado más de 6 meses desde la última aceptación
|
||||
* - La versión activa tiene forzar_reenvio=1 y el usuario aceptó una versión anterior
|
||||
*/
|
||||
private function needsTermsAcceptance(array $user): bool {
|
||||
$terms = $this->getActiveTermsVersion();
|
||||
if (!$terms) return false; // Sin versión activa → no hay términos que aceptar
|
||||
|
||||
// ¿El usuario ya aceptó esta versión exacta?
|
||||
$acceptedVersionId = $user['terms_version_id'] ?? null;
|
||||
$acceptedAt = $user['terms_accepted_at'] ?? null;
|
||||
|
||||
// Nunca aceptó
|
||||
if (!$acceptedAt) return true;
|
||||
|
||||
// Forzar re-aceptación si la versión activa lo requiere y el usuario tiene una versión diferente
|
||||
if (!empty($terms['forzar_reenvio']) && (int)$acceptedVersionId !== (int)$terms['id']) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Han pasado más de 6 meses (26 semanas) desde la última aceptación
|
||||
$sixMonthsAgo = strtotime('-6 months');
|
||||
if (strtotime($acceptedAt) < $sixMonthsAgo) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía el mensaje de términos al usuario y registra el envío en BD.
|
||||
*/
|
||||
private function sendTermsMessage(array $user, string $phoneNumber): void {
|
||||
$terms = $this->getActiveTermsVersion();
|
||||
if (!$terms) return;
|
||||
|
||||
// Usar el mensaje configurado en terms_versions o el de config como fallback
|
||||
$msg = !empty($terms['mensaje_aceptacion'])
|
||||
? $terms['mensaje_aceptacion']
|
||||
: getConfigFromDB('terms_message', '');
|
||||
|
||||
if (!$msg) return;
|
||||
|
||||
// Adjuntar URL del documento solo si es una URL pública (no localhost)
|
||||
if (!empty($terms['documento_url'])) {
|
||||
$docUrl = $terms['documento_url'];
|
||||
$isLocal = (
|
||||
strpos($docUrl, 'localhost') !== false ||
|
||||
strpos($docUrl, '127.0.0.1') !== false ||
|
||||
preg_match('/https?:\/\/\d+\.\d+\.\d+\.\d+:\d+/', $docUrl)
|
||||
);
|
||||
if (!$isLocal) {
|
||||
$msg .= "\n\n📄 Documento: " . $docUrl;
|
||||
}
|
||||
}
|
||||
|
||||
$this->whatsappService->sendTextMessage($phoneNumber, $msg);
|
||||
|
||||
// Marcar usuario como "esperando respuesta de términos"
|
||||
try {
|
||||
$this->db->update(
|
||||
'users',
|
||||
['terms_pending' => 1],
|
||||
'id = :id',
|
||||
['id' => $user['id']]
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
error_log('[BotService] sendTermsMessage – update terms_pending: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Registrar envío en terms_acceptance con estado pendiente
|
||||
try {
|
||||
$this->db->insert('terms_acceptance', [
|
||||
'user_id' => $user['id'],
|
||||
'terms_version_id' => $terms['id'],
|
||||
'phone_number' => $phoneNumber,
|
||||
'estado' => 'pendiente',
|
||||
'fecha_envio' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log('[BotService] sendTermsMessage – insert terms_acceptance: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
error_log("[BotService] sendTermsMessage – sent terms v{$terms['version']} to {$phoneNumber}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa la respuesta del usuario cuando está en estado terms_pending.
|
||||
* Detecta "acepto" / "no acepto" / "rechazo".
|
||||
* Devuelve true si el mensaje fue manejado (para detener el procesamiento normal).
|
||||
*/
|
||||
private function processTermsResponse(array $user, string $phoneNumber, string $messageText): bool {
|
||||
$terms = $this->getActiveTermsVersion();
|
||||
if (!$terms) {
|
||||
// Sin versión activa: limpiar el flag y continuar
|
||||
try {
|
||||
$this->db->update('users', ['terms_pending' => 0], 'id = :id', ['id' => $user['id']]);
|
||||
} catch (Exception $e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim(
|
||||
iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $messageText) ?: $messageText
|
||||
));
|
||||
|
||||
// Detectar aceptación: "acepto", "si acepto", "sí, acepto", "1", etc.
|
||||
$accepted = (bool) preg_match('/\b(acepto|si acepto|si|sí|1|ok|aceptar|de acuerdo|confirmo)\b/', $normalized);
|
||||
|
||||
// Detectar rechazo explícito: "no acepto", "rechazo", "no", "0", etc.
|
||||
$rejected = !$accepted && (bool) preg_match('/\b(no acepto|no|rechazo|rechazar|negar|0|rechaz[oa])\b/', $normalized);
|
||||
|
||||
if (!$accepted && !$rejected) {
|
||||
// Respuesta no reconocida → volver a preguntar
|
||||
$retry = "No entendí tu respuesta. Por favor responde *Acepto* o *No acepto*.";
|
||||
$this->whatsappService->sendTextMessage($phoneNumber, $retry);
|
||||
return true;
|
||||
}
|
||||
|
||||
$estado = $accepted ? 'aceptado' : 'rechazado';
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
// Actualizar el último registro pendiente de esta versión
|
||||
try {
|
||||
$this->db->query(
|
||||
"UPDATE terms_acceptance SET estado = :estado, fecha_respuesta = :now
|
||||
WHERE user_id = :uid AND terms_version_id = :vid AND estado = 'pendiente'
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
['estado' => $estado, 'now' => $now, 'uid' => $user['id'], 'vid' => $terms['id']]
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
error_log('[BotService] processTermsResponse – update acceptance: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
if ($accepted) {
|
||||
// Actualizar usuario: aceptó términos
|
||||
try {
|
||||
$this->db->update('users', [
|
||||
'terms_pending' => 0,
|
||||
'terms_accepted_at' => $now,
|
||||
'terms_version_id' => $terms['id'],
|
||||
// Limpiar forzar_reenvio para este usuario (lo manejamos por versión)
|
||||
], 'id = :id', ['id' => $user['id']]);
|
||||
} catch (Exception $e) {
|
||||
error_log('[BotService] processTermsResponse – update user accepted: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Continuar con el flujo normal (welcome si es nuevo)
|
||||
if ($this->isNewUser($user['id'])) {
|
||||
$this->sendWelcomeMessage($phoneNumber);
|
||||
} else {
|
||||
// Ya había interactuado antes → solo confirmar y mostrar menú
|
||||
$this->showMainMenu($phoneNumber);
|
||||
}
|
||||
} else {
|
||||
// Rechazó — enviar mensaje de rechazo y dejar terms_pending=1 para que pueda aceptar después
|
||||
$rejMsg = !empty($terms['mensaje_rechazo'])
|
||||
? $terms['mensaje_rechazo']
|
||||
: getConfigFromDB('terms_rejected_message', 'Lamentablemente no podemos proceder sin tu aceptación. Cuando estés listo, responde *Acepto*.');
|
||||
|
||||
$this->whatsappService->sendTextMessage($phoneNumber, $rejMsg);
|
||||
|
||||
// Mantener terms_pending = 1 para que la próxima respuesta sea evaluada
|
||||
}
|
||||
|
||||
error_log("[BotService] processTermsResponse – user {$user['id']} {$estado} terms v{$terms['version']}");
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FIN TÉRMINOS Y CONDICIONES
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Verificar si es un nuevo usuario
|
||||
* Ahora: reenvía el welcome si no se envió o si el último welcome fue hace >= 6 horas
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
/**
|
||||
* ver_formulario_enviado.php — Ver respuesta de un formulario (imprimible / PDF)
|
||||
* Requiere: admin o enfermero autenticado
|
||||
* URL: ver_formulario_enviado.php?id=ENVIO_ID
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: login.php'); exit; }
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$id) { http_response_code(400); die('ID requerido'); }
|
||||
|
||||
// ── Cargar datos ──────────────────────────────────────────────────────
|
||||
$db = Database::getInstance();
|
||||
|
||||
$envio = $db->fetch(
|
||||
"SELECT e.*, f.nombre AS form_nombre, f.categoria, f.descripcion AS form_descripcion,
|
||||
f.esquema, f.permite_firma, f.requiere_firma,
|
||||
f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina,
|
||||
p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
|
||||
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email
|
||||
FROM lab_form_envios e
|
||||
JOIN lab_formularios f ON f.id = e.formulario_id
|
||||
LEFT JOIN lab_pacientes p ON p.id = e.paciente_id
|
||||
LEFT JOIN admin_users u ON u.id = e.enviado_por
|
||||
WHERE e.id = ?",
|
||||
[$id]
|
||||
);
|
||||
|
||||
if (!$envio) { http_response_code(404); die('Formulario no encontrado'); }
|
||||
|
||||
// Enfermero solo puede ver sus propios envíos
|
||||
if (isEnfermero()) {
|
||||
$uid = (int)($_SESSION['admin_user']['id'] ?? 0);
|
||||
if ($envio['enviado_por'] != $uid) { http_response_code(403); die('Sin acceso'); }
|
||||
}
|
||||
|
||||
// ── Config global del lab ─────────────────────────────────────────────
|
||||
$cfgRows = $db->fetchAll('SELECT clave, valor FROM lab_config WHERE valor != ""');
|
||||
$cfg = [];
|
||||
foreach ($cfgRows as $r) { $cfg[$r['clave']] = $r['valor']; }
|
||||
|
||||
// Merge: per-form override > global config
|
||||
$docColor = $envio['doc_color'] ?: ($cfg['doc_color'] ?? '#1565c0');
|
||||
$docEncabezado = $envio['doc_encabezado'] ?: ($cfg['empresa_nombre'] ?? '');
|
||||
$docSubtitulo = $envio['doc_subtitulo'] ?: ($cfg['empresa_subtitulo'] ?? '');
|
||||
$docLogo = $envio['doc_logo_base64'] ?: ($cfg['doc_logo_base64'] ?? '');
|
||||
$docPiePagina = $envio['doc_pie_pagina'] ?: ($cfg['doc_pie_pagina'] ?? '');
|
||||
$docDireccion = $cfg['empresa_direccion'] ?? '';
|
||||
$docTelefono = $cfg['empresa_telefono'] ?? '';
|
||||
$docEmail = $cfg['empresa_email'] ?? '';
|
||||
$docCiudad = $cfg['empresa_ciudad'] ?? '';
|
||||
|
||||
$esquema = json_decode($envio['esquema'], true) ?? [];
|
||||
$datosCliente = json_decode($envio['datos_cliente'] ?? '{}', true) ?? [];
|
||||
$datosPrefilled = json_decode($envio['datos_prefilled'] ?? '{}', true) ?? [];
|
||||
$todos = array_merge($datosPrefilled, $datosCliente);
|
||||
|
||||
// Mapa id → label
|
||||
$labelMap = [];
|
||||
foreach ($esquema as $c) {
|
||||
if (!empty($c['id'])) $labelMap[$c['id']] = $c['label'] ?? $c['id'];
|
||||
}
|
||||
|
||||
$completado = ($envio['completado_en'] ?? '') ? date('d/m/Y H:i', strtotime($envio['completado_en'])) : '—';
|
||||
$creado = ($envio['created_at'] ?? '') ? date('d/m/Y H:i', strtotime($envio['created_at'])) : '—';
|
||||
|
||||
function esc2(mixed $v): string {
|
||||
if (is_array($v)) return htmlspecialchars(implode(', ', $v), ENT_QUOTES);
|
||||
return htmlspecialchars((string)($v ?? ''), ENT_QUOTES);
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= esc2($envio['form_nombre']) ?> — Respuesta</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { font-size: 14px; background: #f4f7fc; }
|
||||
|
||||
/* ── Barra de acciones (se oculta al imprimir) ── */
|
||||
.action-bar {
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
background: <?= htmlspecialchars($docColor) ?>; color: #fff;
|
||||
padding: 10px 20px; display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.action-bar a { color: rgba(255,255,255,.8); text-decoration: none; font-size: 13px; }
|
||||
.action-bar a:hover { color: #fff; }
|
||||
|
||||
/* ── Documento ──────────────────────────────────── */
|
||||
.doc-wrap { max-width: 780px; margin: 24px auto; background: #fff;
|
||||
border-radius: 10px; box-shadow: 0 2px 20px rgba(0,0,0,.12);
|
||||
overflow: hidden; }
|
||||
.doc-header { background: <?= htmlspecialchars($docColor) ?>;
|
||||
color: #fff; padding: 24px 32px 20px; }
|
||||
.doc-header-inner { display: flex; align-items: flex-start; gap: 16px; }
|
||||
.doc-header-logo { flex-shrink: 0; }
|
||||
.doc-header-logo img { max-height: 64px; max-width: 100px; border-radius: 6px;
|
||||
background: rgba(255,255,255,.15); padding: 4px; }
|
||||
.doc-header-text { flex: 1; }
|
||||
.doc-header-text h4 { margin: 0 0 2px; font-size: 15px; font-weight: 700;
|
||||
opacity: .9; letter-spacing: .01em; }
|
||||
.doc-header-text .doc-subtitulo { font-size: 12px; opacity: .75; margin-bottom: 4px; }
|
||||
.doc-header-text .doc-contacto { font-size: 11px; opacity: .7; }
|
||||
.doc-header-text h3 { margin: 8px 0 0; font-size: 18px; font-weight: 700;
|
||||
border-top: 1px solid rgba(255,255,255,.3); padding-top: 8px; }
|
||||
.doc-header-badge { flex-shrink: 0; }
|
||||
|
||||
.doc-body { padding: 28px 32px; }
|
||||
|
||||
/* ── Sección ────────────────────────────────────── */
|
||||
.section-title { font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .05em; color: #6c757d; border-bottom: 1px solid #dee2e6;
|
||||
padding-bottom: 6px; margin: 24px 0 14px; }
|
||||
.section-title:first-child { margin-top: 0; }
|
||||
|
||||
/* ── Fila campo ─────────────────────────────────── */
|
||||
.campo-row { display: flex; gap: 16px; padding: 6px 0;
|
||||
border-bottom: 1px solid #f0f0f0; }
|
||||
.campo-row:last-child { border-bottom: none; }
|
||||
.campo-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; padding-top: 1px; }
|
||||
.campo-valor { flex: 1; font-size: 13px; font-weight: 600; color: #212529;
|
||||
word-break: break-word; }
|
||||
|
||||
/* ── Separador del esquema ──────────────────────── */
|
||||
.esquema-sep { font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .05em; color: <?= htmlspecialchars($docColor) ?>; margin: 20px 0 8px;
|
||||
border-bottom: 2px solid <?= htmlspecialchars($docColor) ?>; padding-bottom: 4px; }
|
||||
|
||||
/* ── Estado badge ───────────────────────────────── */
|
||||
.estado-badge { display: inline-block; padding: 3px 10px; border-radius: 20px;
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||
.estado-firmado { background: #d1fae5; color: #065f46; }
|
||||
.estado-completado { background: #dbeafe; color: #1e40af; }
|
||||
.estado-pendiente { background: #fef3c7; color: #92400e; }
|
||||
|
||||
/* ── Firma ──────────────────────────────────────── */
|
||||
.firma-box { background: #f8faff; border: 1px solid #c9d8ff;
|
||||
border-radius: 8px; padding: 14px; display: inline-block; margin-top: 8px; }
|
||||
.firma-box img { max-height: 140px; max-width: 340px; display: block; }
|
||||
|
||||
/* ── Footer del doc ─────────────────────────────── */
|
||||
.doc-footer { background: #f8faff; border-top: 1px solid #e9ecef;
|
||||
padding: 14px 32px; font-size: 11px; color: #6c757d;
|
||||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 6px; }
|
||||
.hash-short { font-family: monospace; opacity: .7; }
|
||||
|
||||
/* ── Sello SHA-256 ──────────────────────────────── */
|
||||
.hash-seal { border: 1px solid #c3d3f7; border-radius: 8px; overflow: hidden; }
|
||||
.hash-seal-header { background: <?= htmlspecialchars($docColor) ?>; color: #fff; padding: 8px 14px;
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .05em; }
|
||||
.hash-seal-body { background: #f0f5ff; padding: 12px 14px; }
|
||||
.hash-label { font-size: 11px; color: #6c757d; margin-bottom: 4px; }
|
||||
.hash-value { display: block; font-family: monospace; font-size: 11px;
|
||||
color: #1e3a6e; word-break: break-all; background: #fff;
|
||||
border: 1px solid #d0dcf7; border-radius: 4px; padding: 6px 8px; }
|
||||
.hash-hint { font-size: 11px; color: #6c757d; }
|
||||
.hash-hint a { color: #1565c0; word-break: break-all; }
|
||||
@media print {
|
||||
.hash-seal { border-color: #000; }
|
||||
.hash-seal-header { background: #000 !important; -webkit-print-color-adjust:exact; print-color-adjust:exact; }
|
||||
}
|
||||
|
||||
/* ═══════ ESTILOS DE IMPRESIÓN ═══════════════════ */
|
||||
@media print {
|
||||
body { background: #fff !important; font-size: 12px; }
|
||||
.action-bar { display: none !important; }
|
||||
.doc-wrap { margin: 0; border-radius: 0; box-shadow: none; max-width: 100%; }
|
||||
.doc-header { -webkit-print-color-adjust: exact; print-color-adjust: exact; padding: 18px 22px; }
|
||||
.doc-body { padding: 18px 22px; }
|
||||
.doc-footer { padding: 10px 22px; }
|
||||
@page { margin: 1cm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Barra de acciones ──────────────────────────────────────────── -->
|
||||
<div class="action-bar no-print">
|
||||
<a href="lab_formularios.php#envios"><i class="fas fa-arrow-left me-1"></i>Volver</a>
|
||||
<div style="flex:1"></div>
|
||||
<button onclick="window.print()" class="btn btn-warning btn-sm fw-semibold">
|
||||
<i class="fas fa-file-pdf me-2"></i>Descargar / Imprimir PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Documento ─────────────────────────────────────────────────── -->
|
||||
<div class="doc-wrap">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="doc-header">
|
||||
<div class="doc-header-inner">
|
||||
<?php if ($docLogo): ?>
|
||||
<div class="doc-header-logo">
|
||||
<img src="<?= htmlspecialchars($docLogo) ?>" alt="Logo">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="doc-header-text">
|
||||
<?php if ($docEncabezado): ?>
|
||||
<h4><?= esc2($docEncabezado) ?></h4>
|
||||
<?php endif; ?>
|
||||
<?php if ($docSubtitulo): ?>
|
||||
<div class="doc-subtitulo"><?= esc2($docSubtitulo) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$contacto = array_filter([$docDireccion, $docCiudad, $docTelefono, $docEmail]);
|
||||
if ($contacto): ?>
|
||||
<div class="doc-contacto"><?= esc2(implode(' · ', $contacto)) ?></div>
|
||||
<?php endif; ?>
|
||||
<h3><?= esc2($envio['form_nombre']) ?></h3>
|
||||
<?php if ($envio['form_descripcion']): ?>
|
||||
<div style="font-size:12px;opacity:.8;margin-top:4px"><?= esc2($envio['form_descripcion']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="doc-header-badge" style="text-align:right">
|
||||
<?php
|
||||
$eBadge = match($envio['estado']) {
|
||||
'firmado' => 'estado-firmado',
|
||||
'completado' => 'estado-completado',
|
||||
default => 'estado-pendiente',
|
||||
};
|
||||
?>
|
||||
<span class="estado-badge <?= $eBadge ?>"><?= esc2($envio['estado']) ?></span>
|
||||
<div style="font-size:10px;opacity:.7;margin-top:4px"><?= esc2($envio['categoria'] ?? '') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-body">
|
||||
|
||||
<!-- Info del paciente -->
|
||||
<?php if ($envio['paciente_nombre']): ?>
|
||||
<div class="section-title"><i class="fas fa-user me-1"></i>Datos del paciente</div>
|
||||
<div class="campo-row">
|
||||
<div class="campo-label">Nombre completo</div>
|
||||
<div class="campo-valor"><?= esc2($envio['paciente_nombre']) ?></div>
|
||||
</div>
|
||||
<?php if ($envio['numero_documento']): ?>
|
||||
<div class="campo-row">
|
||||
<div class="campo-label"><?= esc2($envio['tipo_documento'] ?? 'Documento') ?></div>
|
||||
<div class="campo-valor"><?= esc2($envio['numero_documento']) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($envio['fecha_nacimiento']): ?>
|
||||
<div class="campo-row">
|
||||
<div class="campo-label">Fecha de nacimiento</div>
|
||||
<div class="campo-valor"><?= esc2(date('d/m/Y', strtotime($envio['fecha_nacimiento']))) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($envio['paciente_telefono']): ?>
|
||||
<div class="campo-row">
|
||||
<div class="campo-label">Teléfono</div>
|
||||
<div class="campo-valor"><?= esc2($envio['paciente_telefono']) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($envio['eps']): ?>
|
||||
<div class="campo-row">
|
||||
<div class="campo-label">EPS / Aseguradora</div>
|
||||
<div class="campo-valor"><?= esc2($envio['eps']) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Respuestas del formulario (en orden del esquema) -->
|
||||
<div class="section-title"><i class="fas fa-wpforms me-1"></i>Respuestas del formulario</div>
|
||||
<?php
|
||||
$paciente = $datosPrefilled['__paciente'] ?? [];
|
||||
|
||||
foreach ($esquema as $campo):
|
||||
$tipo = $campo['tipo'] ?? '';
|
||||
if ($tipo === 'separador'): ?>
|
||||
<div class="esquema-sep"><?= esc2($campo['label'] ?? '') ?></div>
|
||||
<?php continue; endif;
|
||||
if ($tipo === 'firma') continue;
|
||||
|
||||
// ── Parrafo estático ──────────────────────────────────
|
||||
if ($tipo === 'parrafo'):
|
||||
$ws = !empty($campo['flujoLibre']) ? 'normal' : 'pre-wrap';
|
||||
?>
|
||||
<div class="mb-3" style="font-size:.88rem;line-height:1.75;color:#444;text-align:justify;white-space:<?= $ws ?>"><?= htmlspecialchars($campo['contenido'] ?? '', ENT_QUOTES) ?></div>
|
||||
<?php continue; endif;
|
||||
|
||||
// ── Parrafo inline (texto con marcadores {key}) ───────
|
||||
if ($tipo === 'parrafo_inline'):
|
||||
$contenido = $campo['contenido'] ?? '';
|
||||
// Sustituir {key} con valor del paciente o de $todos
|
||||
$rendered = preg_replace_callback('/\{([a-z_]+)\}/', function($m) use ($paciente, $todos) {
|
||||
return $paciente[$m[1]] ?? $todos[$m[1]] ?? $m[0];
|
||||
}, $contenido);
|
||||
// Convertir saltos de línea a <br>
|
||||
$rendered = nl2br(htmlspecialchars($rendered, ENT_QUOTES));
|
||||
?>
|
||||
<div class="mb-3" style="font-size:.88rem;line-height:2;color:#444;text-align:justify"><?= $rendered ?></div>
|
||||
<?php continue; endif;
|
||||
|
||||
$cid = $campo['id'] ?? null;
|
||||
if (!$cid) continue;
|
||||
|
||||
// ── Linked: valor viene del paciente prefilled ────────
|
||||
if ($tipo === 'linked') {
|
||||
$lk = $campo['linked_key'] ?? '';
|
||||
$valor = $paciente[$lk] ?? $todos[$cid] ?? null;
|
||||
} else {
|
||||
$valor = $todos[$cid] ?? null;
|
||||
}
|
||||
|
||||
if ($valor === null || $valor === '') continue;
|
||||
$display = is_array($valor) ? implode(', ', $valor) : (string)$valor;
|
||||
?>
|
||||
<div class="campo-row">
|
||||
<div class="campo-label"><?= esc2($campo['label'] ?? $cid) ?></div>
|
||||
<div class="campo-valor"><?= esc2($display) ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Firma digital (dibujo) -->
|
||||
<?php if ($envio['firma_svg']): ?>
|
||||
<div class="section-title mt-4"><i class="fas fa-signature me-1"></i>Firma digital (dibujo)</div>
|
||||
<div class="firma-box">
|
||||
<img src="<?= htmlspecialchars($envio['firma_svg']) ?>" alt="Firma digital">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Firma foto -->
|
||||
<?php if (!empty($datosCliente['__firma_foto'])): ?>
|
||||
<div class="section-title mt-4"><i class="fas fa-camera me-1"></i>Foto de firma / documento</div>
|
||||
<div class="firma-box">
|
||||
<img src="<?= htmlspecialchars($datosCliente['__firma_foto']) ?>" alt="Foto de firma">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Info del envío -->
|
||||
<div class="section-title mt-4"><i class="fas fa-clock me-1"></i>Información del envío</div>
|
||||
<div class="campo-row">
|
||||
<div class="campo-label">Enviado por</div>
|
||||
<div class="campo-valor"><?= esc2($envio['enviado_por_nombre'] ?? '—') ?></div>
|
||||
</div>
|
||||
<div class="campo-row">
|
||||
<div class="campo-label">Fecha de envío</div>
|
||||
<div class="campo-valor"><?= esc2($creado) ?></div>
|
||||
</div>
|
||||
<?php if ($envio['completado_en']): ?>
|
||||
<div class="campo-row">
|
||||
<div class="campo-label">Completado el</div>
|
||||
<div class="campo-valor"><?= esc2($completado) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($envio['ip_cliente']): ?>
|
||||
<div class="campo-row">
|
||||
<div class="campo-label">IP del cliente</div>
|
||||
<div class="campo-valor text-muted small fw-normal"><?= esc2($envio['ip_cliente']) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($envio['hash_verificacion']): ?>
|
||||
<!-- Sello de integridad SHA-256 -->
|
||||
<div class="hash-seal mt-4">
|
||||
<div class="hash-seal-header">
|
||||
<i class="fas fa-shield-alt me-1"></i>
|
||||
Sello de integridad del documento
|
||||
</div>
|
||||
<div class="hash-seal-body">
|
||||
<div class="hash-label">Hash SHA-256 de verificación:</div>
|
||||
<code class="hash-value"><?= esc2($envio['hash_verificacion']) ?></code>
|
||||
<div class="hash-hint mt-2">
|
||||
Verifique la autenticidad en:
|
||||
<a href="verificar_formulario.php?h=<?= urlencode($envio['hash_verificacion']) ?>" target="_blank">
|
||||
<?php
|
||||
$protocol2 = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS']!=='off') ? 'https' : 'http';
|
||||
echo $protocol2.'://'.$_SERVER['HTTP_HOST'].'/verificar_formulario.php?h='.urlencode($envio['hash_verificacion']);
|
||||
?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- /doc-body -->
|
||||
|
||||
<div class="doc-footer">
|
||||
<span>
|
||||
<?php if ($docPiePagina): ?>
|
||||
<?= esc2($docPiePagina) ?> •
|
||||
<?php endif; ?>
|
||||
<i class="fas fa-shield-alt me-1"></i>Generado el <?= date('d/m/Y H:i') ?> • ID #<?= $envio['id'] ?>
|
||||
</span>
|
||||
<?php if ($envio['hash_verificacion']): ?>
|
||||
<span class="hash-short" title="Hash SHA-256"><?= substr($envio['hash_verificacion'],0,16) ?>...</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div><!-- /doc-wrap -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
/**
|
||||
* verificar_formulario.php — Verificación pública de integridad de documentos
|
||||
* Público, sin autenticación.
|
||||
* URL: verificar_formulario.php?h=HASH_SHA256
|
||||
*/
|
||||
require_once 'config/config.php';
|
||||
|
||||
$hash = trim($_GET['h'] ?? '');
|
||||
$valido = false;
|
||||
$envio = null;
|
||||
$error = '';
|
||||
|
||||
if ($hash && preg_match('/^[a-f0-9]{64}$/i', $hash)) {
|
||||
$db = Database::getInstance();
|
||||
$envio = $db->fetch(
|
||||
"SELECT e.id, e.estado, e.completado_en, e.ip_cliente,
|
||||
f.nombre AS form_nombre, f.categoria,
|
||||
p.nombre_completo AS paciente_nombre, p.numero_documento
|
||||
FROM lab_form_envios e
|
||||
JOIN lab_formularios f ON f.id = e.formulario_id
|
||||
LEFT JOIN lab_pacientes p ON p.id = e.paciente_id
|
||||
WHERE e.hash_verificacion = ?",
|
||||
[$hash]
|
||||
);
|
||||
if ($envio) {
|
||||
$valido = in_array($envio['estado'], ['firmado', 'completado']);
|
||||
} else {
|
||||
$error = 'El código de verificación no corresponde a ningún documento registrado.';
|
||||
}
|
||||
} elseif ($hash) {
|
||||
$error = 'El código de verificación no tiene el formato correcto.';
|
||||
}
|
||||
|
||||
function esc3(mixed $v): string {
|
||||
return htmlspecialchars((string)($v ?? ''), ENT_QUOTES);
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verificación de documento — Lab</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f0f4ff; min-height: 100vh; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: flex-start; padding: 40px 16px; }
|
||||
.verify-card { max-width: 560px; width: 100%; background: #fff;
|
||||
border-radius: 12px; overflow: hidden;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.12); }
|
||||
.card-header-bar { padding: 22px 28px; color: #fff; }
|
||||
.card-header-bar.valid { background: linear-gradient(135deg, #1b5e20, #2e7d32); }
|
||||
.card-header-bar.invalid { background: linear-gradient(135deg, #b71c1c, #c62828); }
|
||||
.card-header-bar.neutral { background: linear-gradient(135deg, #1565c0, #0288d1); }
|
||||
.card-body-p { padding: 24px 28px; }
|
||||
|
||||
.result-icon { font-size: 3.5rem; margin-bottom: 12px; }
|
||||
.field-row { display: flex; gap: 12px; padding: 7px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.field-row:last-child { border: none; }
|
||||
.field-label { flex: 0 0 44%; font-size: 12px; color: #6c757d; }
|
||||
.field-val { flex: 1; font-size: 13px; font-weight: 600; }
|
||||
|
||||
.hash-display { font-family: monospace; font-size: 11px; word-break: break-all;
|
||||
background: #f0f5ff; border: 1px solid #c3d3f7; border-radius: 6px;
|
||||
padding: 10px 12px; color: #1e3a6e; margin-top: 12px; }
|
||||
.search-form input { font-size: 13px; font-family: monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="verify-card">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="card-header-bar <?= $hash ? ($valido ? 'valid' : 'invalid') : 'neutral' ?>">
|
||||
<h5 class="fw-bold mb-0 text-center">
|
||||
<i class="fas fa-shield-alt me-2"></i>Verificación de integridad de documentos
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div class="card-body-p">
|
||||
|
||||
<?php if (!$hash): ?>
|
||||
<!-- Formulario de búsqueda -->
|
||||
<p class="text-muted small mb-4 text-center">
|
||||
Ingresa el código SHA-256 que aparece en el documento para verificar su autenticidad.
|
||||
</p>
|
||||
<form method="get" class="search-form">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold small">Código de verificación (SHA-256)</label>
|
||||
<input type="text" name="h" class="form-control" maxlength="64"
|
||||
placeholder="Ej: a3f4b8c1d2e..." required>
|
||||
<div class="form-text">Código de 64 caracteres que aparece en el pie del documento.</div>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100 fw-semibold">
|
||||
<i class="fas fa-search me-1"></i>Verificar documento
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<?php elseif ($valido && $envio): ?>
|
||||
<!-- Documento válido -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="result-icon">✅</div>
|
||||
<h5 class="fw-bold text-success">Documento válido y auténtico</h5>
|
||||
<p class="text-muted small">
|
||||
Este documento fue registrado en nuestro sistema y no ha sido modificado.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-label">Formulario</div>
|
||||
<div class="field-val"><?= esc3($envio['form_nombre']) ?></div>
|
||||
</div>
|
||||
<?php if ($envio['paciente_nombre']): ?>
|
||||
<div class="field-row">
|
||||
<div class="field-label">Paciente</div>
|
||||
<div class="field-val"><?= esc3($envio['paciente_nombre']) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($envio['numero_documento']): ?>
|
||||
<div class="field-row">
|
||||
<div class="field-label">Documento</div>
|
||||
<div class="field-val"><?= esc3($envio['numero_documento']) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="field-row">
|
||||
<div class="field-label">Estado</div>
|
||||
<div class="field-val">
|
||||
<?= $envio['estado'] === 'firmado'
|
||||
? '<span class="badge" style="background:#198754">✍️ Firmado digitalmente</span>'
|
||||
: '<span class="badge" style="background:#0d6efd">✅ Completado</span>' ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($envio['completado_en']): ?>
|
||||
<div class="field-row">
|
||||
<div class="field-label">Fecha de registro</div>
|
||||
<div class="field-val"><?= esc3(date('d/m/Y H:i', strtotime($envio['completado_en']))) ?> UTC</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="field-row">
|
||||
<div class="field-label">ID de registro</div>
|
||||
<div class="field-val text-muted">#<?= (int)$envio['id'] ?></div>
|
||||
</div>
|
||||
|
||||
<div class="hash-display"><?= esc3($hash) ?></div>
|
||||
|
||||
<form method="get" class="search-form mt-4">
|
||||
<input type="text" name="h" class="form-control form-control-sm"
|
||||
placeholder="Verificar otro código…">
|
||||
<button class="btn btn-outline-secondary btn-sm w-100 mt-2">
|
||||
<i class="fas fa-search me-1"></i>Verificar otro
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- Inválido / no encontrado -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="result-icon">❌</div>
|
||||
<h5 class="fw-bold text-danger">Documento no verificado</h5>
|
||||
<p class="text-muted small"><?= esc3($error ?: 'El código no corresponde a ningún documento.') ?></p>
|
||||
</div>
|
||||
|
||||
<?php if ($hash): ?>
|
||||
<div class="hash-display"><?= esc3($hash) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="get" class="search-form mt-4">
|
||||
<input type="text" name="h" value="<?= esc3($hash) ?>"
|
||||
class="form-control form-control-sm" placeholder="Código de verificación…">
|
||||
<button class="btn btn-primary btn-sm w-100 mt-2">
|
||||
<i class="fas fa-search me-1"></i>Intentar de nuevo
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- /card-body-p -->
|
||||
|
||||
<div style="padding: 12px 28px; background: #f8faff; border-top: 1px solid #e9ecef;
|
||||
font-size: 11px; color: #6c757d; text-align: center;">
|
||||
Sistema de verificación de documentos — Generado el <?= date('d/m/Y') ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
/**
|
||||
* Visor de logs del sistema - Accesible desde el navegador
|
||||
* URL: /view_logs.php
|
||||
*/
|
||||
session_start();
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
|
||||
// Proteger: solo admins
|
||||
if (!isset($_SESSION['admin_logged_in']) && !isset($_SESSION['user_id'])) {
|
||||
http_response_code(403);
|
||||
echo 'No autorizado';
|
||||
exit;
|
||||
}
|
||||
|
||||
$logFiles = [
|
||||
'PHP Errors' => '/var/log/php-error.log',
|
||||
'Nginx Errors' => '/var/log/nginx/whatsapp-error.log',
|
||||
'Nginx Access' => '/var/log/nginx/whatsapp-access.log',
|
||||
'App System' => __DIR__ . '/logs/system.log',
|
||||
'App Errors' => __DIR__ . '/logs/app_errors.log',
|
||||
'Worker (hoy)' => __DIR__ . '/logs/worker-' . date('Y-m-d') . '.log',
|
||||
'Scheduled' => __DIR__ . '/logs/scheduled_messages.log',
|
||||
'Supervisor PHP-FPM' => '/var/log/supervisor/php-fpm-error.log',
|
||||
'Supervisor Worker 0' => '/var/log/supervisor/worker-00-error.log',
|
||||
'Supervisor Worker 1' => '/var/log/supervisor/worker-01-error.log',
|
||||
];
|
||||
|
||||
$selected = $_GET['log'] ?? 'App Errors';
|
||||
$lines = intval($_GET['lines'] ?? 100);
|
||||
$filter = trim($_GET['filter'] ?? '');
|
||||
|
||||
$content = '';
|
||||
$selectedFile = $logFiles[$selected] ?? '';
|
||||
|
||||
if ($selectedFile && file_exists($selectedFile)) {
|
||||
// Leer últimas N líneas sin exec()
|
||||
$allLines = file($selectedFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($allLines === false) $allLines = [];
|
||||
$allLines = array_slice($allLines, -$lines);
|
||||
|
||||
if ($filter) {
|
||||
$allLines = array_filter($allLines, function($line) use ($filter) {
|
||||
return stripos($line, $filter) !== false;
|
||||
});
|
||||
}
|
||||
$content = implode("\n", $allLines);
|
||||
} elseif ($selectedFile) {
|
||||
$content = "(Archivo no existe: {$selectedFile})";
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>📋 Visor de Logs</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #1a1a2e; color: #e0e0e0; font-family: monospace; }
|
||||
.log-content {
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #30363d;
|
||||
}
|
||||
.log-content .error-line { color: #f85149; font-weight: bold; }
|
||||
.log-content .warning-line { color: #d29922; }
|
||||
.log-content .info-line { color: #58a6ff; }
|
||||
.toolbar { background: #161b22; padding: 12px 16px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #30363d; }
|
||||
.btn-log { border-radius: 6px; font-size: 12px; }
|
||||
.btn-log.active { background: #238636; border-color: #238636; }
|
||||
h4 { color: #58a6ff; }
|
||||
.badge-file { font-size: 10px; color: #8b949e; }
|
||||
#auto-refresh-indicator { display: none; }
|
||||
#auto-refresh-indicator.active { display: inline-block; animation: blink 1s infinite; }
|
||||
@keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">📋 Visor de Logs del Sistema</h4>
|
||||
<div>
|
||||
<a href="conversations.php" class="btn btn-sm btn-outline-light">← Volver</a>
|
||||
<button onclick="location.reload()" class="btn btn-sm btn-outline-info ms-2">🔄 Refrescar</button>
|
||||
<button id="auto-refresh-btn" onclick="toggleAutoRefresh()" class="btn btn-sm btn-outline-warning ms-2">⏱ Auto-refresh</button>
|
||||
<span id="auto-refresh-indicator" class="ms-2 text-warning">●</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<form method="GET" class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<div>
|
||||
<select name="log" class="form-select form-select-sm" style="background:#0d1117; color:#c9d1d9; border-color:#30363d;" onchange="this.form.submit()">
|
||||
<?php foreach ($logFiles as $name => $path): ?>
|
||||
<option value="<?= htmlspecialchars($name) ?>" <?= $name === $selected ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($name) ?> <?= file_exists($path) ? '' : '(no existe)' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<select name="lines" class="form-select form-select-sm" style="background:#0d1117; color:#c9d1d9; border-color:#30363d;">
|
||||
<?php foreach ([50, 100, 200, 500, 1000] as $n): ?>
|
||||
<option value="<?= $n ?>" <?= $lines === $n ? 'selected' : '' ?>><?= $n ?> líneas</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<input type="text" name="filter" class="form-control form-control-sm" placeholder="Filtrar por texto..."
|
||||
value="<?= htmlspecialchars($filter) ?>" style="background:#0d1117; color:#c9d1d9; border-color:#30363d;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary btn-log">Aplicar</button>
|
||||
<?php if ($filter): ?>
|
||||
<a href="?log=<?= urlencode($selected) ?>&lines=<?= $lines ?>" class="btn btn-sm btn-outline-secondary btn-log">Limpiar filtro</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<?php if ($selectedFile): ?>
|
||||
<div class="badge-file mt-1">📁 <?= htmlspecialchars($selectedFile) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="log-content" id="log-content"><?php
|
||||
if (!$content) {
|
||||
echo "(vacío)";
|
||||
} else {
|
||||
// Colorear líneas según nivel
|
||||
$lines_arr = explode("\n", htmlspecialchars($content));
|
||||
foreach ($lines_arr as $line) {
|
||||
if (preg_match('/error|fatal|exception|fail/i', $line)) {
|
||||
echo '<span class="error-line">' . $line . '</span>' . "\n";
|
||||
} elseif (preg_match('/warning|warn/i', $line)) {
|
||||
echo '<span class="warning-line">' . $line . '</span>' . "\n";
|
||||
} elseif (preg_match('/info|notice/i', $line)) {
|
||||
echo '<span class="info-line">' . $line . '</span>' . "\n";
|
||||
} else {
|
||||
echo $line . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
?></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-scroll al final
|
||||
const logEl = document.getElementById('log-content');
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
|
||||
let autoRefreshInterval = null;
|
||||
function toggleAutoRefresh() {
|
||||
const btn = document.getElementById('auto-refresh-btn');
|
||||
const indicator = document.getElementById('auto-refresh-indicator');
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
btn.classList.remove('btn-warning');
|
||||
btn.classList.add('btn-outline-warning');
|
||||
indicator.classList.remove('active');
|
||||
} else {
|
||||
autoRefreshInterval = setInterval(() => location.reload(), 5000);
|
||||
btn.classList.remove('btn-outline-warning');
|
||||
btn.classList.add('btn-warning');
|
||||
indicator.classList.add('active');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user