Compare commits
39
Commits
381c2ca04e
..
main
@@ -21,6 +21,53 @@ requireAuthentication();
|
|||||||
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le pide el teléfono a quien lo tiene oculto en WhatsApp, una sola vez.
|
||||||
|
*
|
||||||
|
* Solo aplica a quien se identifica con un BSUID: de esa persona no tenemos
|
||||||
|
* número, y sin él el laboratorio no puede llamarla. Se le manda el botón que
|
||||||
|
* Meta dispone para esto y ella decide si lo comparte; si acepta, el webhook
|
||||||
|
* recibe el teléfono y lo vincula solo.
|
||||||
|
*
|
||||||
|
* No se insiste: pedir los datos una vez es razonable, repetirlo en cada
|
||||||
|
* trámite es acoso. Tampoco se interrumpe la creación de la ficha si el envío
|
||||||
|
* falla, porque la ficha es lo importante.
|
||||||
|
*
|
||||||
|
* @return string qué pasó, para que la interfaz lo pueda mostrar
|
||||||
|
*/
|
||||||
|
function pedirContactoSiHaceFalta(Database $db, int $userId): string {
|
||||||
|
$u = $db->fetch(
|
||||||
|
'SELECT phone_number, contacto_pedido_at FROM users WHERE id = ?',
|
||||||
|
[$userId]
|
||||||
|
);
|
||||||
|
if (!$u) return 'usuario_no_encontrado';
|
||||||
|
if (!esBsuid($u['phone_number'])) return 'no_hace_falta'; // ya tenemos su número
|
||||||
|
if (!empty($u['contacto_pedido_at'])) return 'ya_se_pidio';
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once __DIR__ . '/../../services/WhatsAppService.php';
|
||||||
|
$wa = new WhatsAppService();
|
||||||
|
$texto = getConfigFromDB(
|
||||||
|
'whatsapp_texto_pedir_contacto',
|
||||||
|
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?'
|
||||||
|
);
|
||||||
|
|
||||||
|
$r = $wa->pedirContacto($u['phone_number'], $texto);
|
||||||
|
if (!$r) {
|
||||||
|
error_log('[crear_desde_whatsapp] WhatsApp rechazó la solicitud de contacto del usuario ' . $userId);
|
||||||
|
return 'fallo_envio';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se marca solo si Meta aceptó: si falló, hay que poder reintentarlo
|
||||||
|
$db->update('users', ['contacto_pedido_at' => date('Y-m-d H:i:s')], 'id = ?', [$userId]);
|
||||||
|
return 'pedido';
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('[crear_desde_whatsapp] Error pidiendo el contacto: ' . $e->getMessage());
|
||||||
|
return 'fallo_envio';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── GET: solo_paciente ─────────────────────────────────────────────────────
|
// ── GET: solo_paciente ─────────────────────────────────────────────────────
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||||
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
|||||||
$pacienteRepo = new Paciente();
|
$pacienteRepo = new Paciente();
|
||||||
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
||||||
$paciente = $pacienteRepo->obtener($pacienteId);
|
$paciente = $pacienteRepo->obtener($pacienteId);
|
||||||
echo json_encode(['success' => true, 'paciente' => $paciente]);
|
|
||||||
|
// Si la persona oculta su teléfono, la ficha queda sin número. Es el momento
|
||||||
|
// de pedírselo: se le manda el botón de WhatsApp una sola vez.
|
||||||
|
$contactoPedido = pedirContactoSiHaceFalta($db, (int) $conv['user_id']);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'paciente' => $paciente,
|
||||||
|
'contacto_pedido' => $contactoPedido,
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/lab/get_tv_log.php — Bitácora de la voz del televisor.
|
||||||
|
* Para la pantalla de Actividad: resumen de salud + últimos fallos, en llano.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireMethod('GET');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
// Resumen de los últimos 7 días por evento
|
||||||
|
$resumen = $pdo->query(
|
||||||
|
"SELECT evento, COUNT(*) n FROM turnero_tv_log
|
||||||
|
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
GROUP BY evento"
|
||||||
|
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
// Últimos incidentes (lo normal —start/end— no se lista: solo lo que falló)
|
||||||
|
$stmt = $pdo->query(
|
||||||
|
"SELECT evento, detalle, created_at FROM turnero_tv_log
|
||||||
|
WHERE evento IN ('mudo', 'sin_start', 'error', 'lag')
|
||||||
|
ORDER BY id DESC LIMIT 30"
|
||||||
|
);
|
||||||
|
$incidentes = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||||
|
$d = json_decode($r['detalle'] ?? '{}', true) ?: [];
|
||||||
|
$incidentes[] = [
|
||||||
|
'cuando' => $r['created_at'],
|
||||||
|
'evento' => $r['evento'],
|
||||||
|
'turno' => $d['cod'] ?? null,
|
||||||
|
'voz' => $d['voz'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$anuncios = (int)($resumen['start'] ?? 0);
|
||||||
|
$mudos = (int)($resumen['mudo'] ?? 0);
|
||||||
|
$avisos = (int)($resumen['sin_start'] ?? 0) + (int)($resumen['error'] ?? 0);
|
||||||
|
|
||||||
|
jsonOk([
|
||||||
|
'anuncios_7d' => $anuncios,
|
||||||
|
'completos_7d' => (int)($resumen['end'] ?? 0),
|
||||||
|
'mudos_7d' => $mudos,
|
||||||
|
'avisos_7d' => $avisos,
|
||||||
|
'incidentes' => $incidentes,
|
||||||
|
]);
|
||||||
+112
-6
@@ -113,24 +113,41 @@ class WhatsAppWebhook {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extraer nombres de contactos del payload (contacts[].profile.name)
|
// Extraer nombres de contactos del payload (contacts[].profile.name).
|
||||||
|
// Se indexa por wa_id y también por user_id (BSUID): desde que Meta desplegó
|
||||||
|
// los nombres de usuario, quien oculta su teléfono llega sin wa_id.
|
||||||
$contactNames = [];
|
$contactNames = [];
|
||||||
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
||||||
foreach ($value['contacts'] as $contact) {
|
foreach ($value['contacts'] as $contact) {
|
||||||
$waId = $contact['wa_id'] ?? null;
|
|
||||||
$name = $contact['profile']['name'] ?? null;
|
$name = $contact['profile']['name'] ?? null;
|
||||||
if ($waId && $name) {
|
if (!$name) continue;
|
||||||
$contactNames[$waId] = $name;
|
foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
|
||||||
|
if ($clave) $contactNames[$clave] = $name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($items as $message) {
|
foreach ($items as $message) {
|
||||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos
|
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos.
|
||||||
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
|
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
|
||||||
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
|
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
|
||||||
$timestamp = $message['timestamp'] ?? null;
|
$timestamp = $message['timestamp'] ?? null;
|
||||||
|
|
||||||
|
// Meta manda el BSUID en todos los mensajes, traigan teléfono o no.
|
||||||
|
$bsuid = $message['from_user_id'] ?? null;
|
||||||
|
|
||||||
|
// Si la persona oculta su teléfono, `from` y `wa_id` no llegan. En ese caso
|
||||||
|
// se busca por BSUID: si ya escribió antes mostrando su número, se le
|
||||||
|
// reconoce y se le sigue respondiendo a ese teléfono.
|
||||||
|
if (empty($phoneNumber) && $bsuid) {
|
||||||
|
$conocido = $this->db->fetch(
|
||||||
|
"SELECT phone_number FROM users WHERE bsuid = :b", ['b' => $bsuid]
|
||||||
|
);
|
||||||
|
// Si no se le conoce, el BSUID hace de identificador: sirve para responderle,
|
||||||
|
// aunque no permita cruzarlo con el paciente ni con el turnero.
|
||||||
|
$phoneNumber = $conocido['phone_number'] ?? $bsuid;
|
||||||
|
}
|
||||||
|
|
||||||
// Si falta lo crítico, saltar
|
// Si falta lo crítico, saltar
|
||||||
if (empty($phoneNumber) || empty($messageId)) {
|
if (empty($phoneNumber) || empty($messageId)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -148,7 +165,7 @@ class WhatsAppWebhook {
|
|||||||
|
|
||||||
// Obtener o crear usuario
|
// Obtener o crear usuario
|
||||||
$user = $this->getUserByPhone($phoneNumber);
|
$user = $this->getUserByPhone($phoneNumber);
|
||||||
$contactName = $contactNames[$phoneNumber] ?? null;
|
$contactName = $contactNames[$phoneNumber] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : null);
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
$userId = $this->createUser($phoneNumber);
|
$userId = $this->createUser($phoneNumber);
|
||||||
$user = $this->getUserById($userId);
|
$user = $this->getUserById($userId);
|
||||||
@@ -173,6 +190,20 @@ class WhatsAppWebhook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guardar la equivalencia BSUID↔usuario mientras la persona todavía muestra
|
||||||
|
// su teléfono. El día que lo oculte, ese registro es lo único que permitirá
|
||||||
|
// reconocerla, así que se anota en cada mensaje y no solo la primera vez.
|
||||||
|
if ($bsuid && !empty($user['id']) && ($user['bsuid'] ?? null) !== $bsuid) {
|
||||||
|
try {
|
||||||
|
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$user['id']]);
|
||||||
|
$user['bsuid'] = $bsuid;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Choca si ese BSUID ya está en otro usuario (la persona cambió de
|
||||||
|
// número). No es motivo para perder el mensaje: se sigue adelante.
|
||||||
|
error_log('[webhook] No se pudo guardar el BSUID ' . $bsuid . ': ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Procesar diferentes tipos de mensaje
|
// Procesar diferentes tipos de mensaje
|
||||||
$messageText = '';
|
$messageText = '';
|
||||||
$messageType = 'text';
|
$messageType = 'text';
|
||||||
@@ -191,6 +222,28 @@ class WhatsAppWebhook {
|
|||||||
'reaction_emoji' => $emoji
|
'reaction_emoji' => $emoji
|
||||||
];
|
];
|
||||||
|
|
||||||
|
} elseif (($message['type'] ?? '') === 'contacts') {
|
||||||
|
// La persona compartió su contacto, sea por el botón que se le pidió
|
||||||
|
// o a mano. Es la única forma de obtener el teléfono de quien lo oculta.
|
||||||
|
$messageType = 'contacts';
|
||||||
|
$messageText = json_encode($message['contacts'] ?? []);
|
||||||
|
|
||||||
|
$telefonoCompartido = null;
|
||||||
|
foreach ($message['contacts'] ?? [] as $c) {
|
||||||
|
foreach ($c['phones'] ?? [] as $t) {
|
||||||
|
// wa_id ya viene normalizado; `phone` puede traer espacios y signos
|
||||||
|
$candidato = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||||
|
if ($candidato) {
|
||||||
|
$telefonoCompartido = preg_replace('/[^0-9]/', '', $candidato);
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($telefonoCompartido && !empty($user['id'])) {
|
||||||
|
$this->vincularTelefonoCompartido($user, $telefonoCompartido, $bsuid);
|
||||||
|
}
|
||||||
|
|
||||||
} elseif (isset($message['interactive'])) {
|
} elseif (isset($message['interactive'])) {
|
||||||
// Interactive replies (list or button) - normalize to text so bot can process
|
// Interactive replies (list or button) - normalize to text so bot can process
|
||||||
$messageType = 'text';
|
$messageType = 'text';
|
||||||
@@ -450,6 +503,59 @@ class WhatsAppWebhook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vincula el teléfono que la persona acaba de compartir con el usuario que
|
||||||
|
* hasta ahora solo se conocía por su BSUID.
|
||||||
|
*
|
||||||
|
* Puede haber dos registros de la misma persona: el viejo, de cuando escribía
|
||||||
|
* mostrando el número, y el nuevo creado con el BSUID de identificador. No se
|
||||||
|
* fusionan aquí (implicaría mover conversaciones, estados y aceptación de
|
||||||
|
* términos, y una fusión mal hecha mezcla historias clínicas de dos personas):
|
||||||
|
* se deja el registro con el teléfono como el bueno y se marca el otro, para
|
||||||
|
* que alguien lo revise.
|
||||||
|
*
|
||||||
|
* @return bool si el teléfono quedó vinculado
|
||||||
|
*/
|
||||||
|
private function vincularTelefonoCompartido(&$user, $telefono, $bsuid) {
|
||||||
|
// Ya lo teníamos: nada que hacer
|
||||||
|
if (($user['phone_number'] ?? null) === $telefono) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existente = $this->db->fetch(
|
||||||
|
"SELECT id FROM users WHERE phone_number = :t AND id <> :id",
|
||||||
|
['t' => $telefono, 'id' => $user['id']]
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($existente) {
|
||||||
|
// El registro bueno es el que tiene el teléfono. Se le pasa el BSUID
|
||||||
|
// para que a partir de ahora se le reconozca por ahí.
|
||||||
|
if ($bsuid) {
|
||||||
|
$this->db->update('users', ['bsuid' => null], 'id = ?', [$user['id']]);
|
||||||
|
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$existente['id']]);
|
||||||
|
}
|
||||||
|
error_log(sprintf(
|
||||||
|
'[webhook] BSUID %s compartió el teléfono %s, que ya era del usuario %d. ' .
|
||||||
|
'El usuario %d queda duplicado y hay que revisarlo a mano.',
|
||||||
|
$bsuid, $telefono, $existente['id'], $user['id']
|
||||||
|
));
|
||||||
|
$user = $this->getUserById($existente['id']) ?: $user;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No había otro registro: el placeholder pasa a tener el teléfono real
|
||||||
|
$this->db->update('users', ['phone_number' => $telefono], 'id = ?', [$user['id']]);
|
||||||
|
$user['phone_number'] = $telefono;
|
||||||
|
error_log(sprintf('[webhook] BSUID %s quedó vinculado al teléfono %s', $bsuid, $telefono));
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('[webhook] Error vinculando el teléfono compartido: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function getUserByPhone($phoneNumber) {
|
private function getUserByPhone($phoneNumber) {
|
||||||
return $this->db->fetch(
|
return $this->db->fetch(
|
||||||
"SELECT * FROM users WHERE phone_number = :phone",
|
"SELECT * FROM users WHERE phone_number = :phone",
|
||||||
|
|||||||
@@ -7,6 +7,15 @@
|
|||||||
require_once __DIR__ . '/../../classes/Database.php';
|
require_once __DIR__ . '/../../classes/Database.php';
|
||||||
require_once __DIR__ . '/ActividadAdmin.php';
|
require_once __DIR__ . '/ActividadAdmin.php';
|
||||||
|
|
||||||
|
// esBsuid() vive en config.php; se garantiza aquí por si esta clase se incluye
|
||||||
|
// directamente, sin pasar por el arranque del ERP.
|
||||||
|
if (!function_exists('esBsuid')) {
|
||||||
|
$configPaciente = __DIR__ . '/../../config/config.php';
|
||||||
|
if (file_exists($configPaciente)) {
|
||||||
|
require_once $configPaciente;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class Paciente {
|
class Paciente {
|
||||||
|
|
||||||
private Database $db;
|
private Database $db;
|
||||||
@@ -258,10 +267,19 @@ class Paciente {
|
|||||||
[$userId]
|
[$userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Quien oculta su teléfono en WhatsApp se identifica con un BSUID, que ocupa
|
||||||
|
// el lugar del número en `users`. No es un teléfono: guardarlo aquí dejaría
|
||||||
|
// en la historia clínica un dato falso con apariencia de número real, porque
|
||||||
|
// normalizarTelefono() le quita el punto y las letras y lo deja en 16 dígitos.
|
||||||
|
// Mejor la ficha sin teléfono, que es la verdad: no lo tenemos.
|
||||||
|
$identificador = $user['phone_number'] ?? null;
|
||||||
|
$esIdentificadorSinTelefono = esBsuid($identificador);
|
||||||
|
|
||||||
return $this->crear([
|
return $this->crear([
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
|
'nombre_completo'=> $user['name']
|
||||||
'telefono' => $user['phone_number'] ?? null,
|
?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
|
||||||
|
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,24 @@ function deleteConfigFromDB($key) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!function_exists('esBsuid')) {
|
||||||
|
/**
|
||||||
|
* ¿Este identificador es un BSUID de Meta y no un número de teléfono?
|
||||||
|
*
|
||||||
|
* Desde que WhatsApp permite ocultar el número, quien lo oculta llega
|
||||||
|
* identificado solo por su BSUID, con la forma "CO.1761088155094242".
|
||||||
|
* Ese valor ocupa el lugar del teléfono dentro del bot, así que hay que
|
||||||
|
* distinguirlo antes de tratarlo como si fuera un número real: guardarlo
|
||||||
|
* en un campo de teléfono deja un dato falso con toda la pinta de verdadero.
|
||||||
|
*
|
||||||
|
* @param mixed $valor
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
function esBsuid($valor) {
|
||||||
|
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $valor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Limpia el cache estático de configuraciones
|
* Limpia el cache estático de configuraciones
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ class Router
|
|||||||
private const PUBLIC_ROUTES = [
|
private const PUBLIC_ROUTES = [
|
||||||
'turnero/display',
|
'turnero/display',
|
||||||
'turnero/kiosko',
|
'turnero/kiosko',
|
||||||
|
// Tablet de firma del paciente: la manipula el público y nadie va a
|
||||||
|
// iniciar sesión en ella cada mañana. No queda abierta: se identifica
|
||||||
|
// por la cookie del dispositivo y sin ella no muestra dato alguno.
|
||||||
|
'turnero/firma',
|
||||||
|
// Prueba de voces: hay que abrirla EN el televisor para saber qué voces
|
||||||
|
// tiene ese equipo, y allí no hay sesión iniciada. No expone nada: solo
|
||||||
|
// lista las voces del navegador y lee una frase de ejemplo inventada.
|
||||||
|
'turnero/voces',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
|
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
<option value="enfermeras">Enfermeras</option>
|
<option value="enfermeras">Enfermeras</option>
|
||||||
<option value="formularios">Formularios</option>
|
<option value="formularios">Formularios</option>
|
||||||
<option value="asignaciones">Asignaciones</option>
|
<option value="asignaciones">Asignaciones</option>
|
||||||
|
<option value="turnero">Turnero</option>
|
||||||
|
<option value="turnero_config">Turnero · configuración</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
@@ -85,6 +87,11 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
<option value="enviar">enviar</option>
|
<option value="enviar">enviar</option>
|
||||||
<option value="reprogramar">reprogramar</option>
|
<option value="reprogramar">reprogramar</option>
|
||||||
<option value="cambiar_estado">cambiar_estado</option>
|
<option value="cambiar_estado">cambiar_estado</option>
|
||||||
|
<option value="quitar_consentimiento">quitar_consentimiento</option>
|
||||||
|
<option value="resetear_consentimiento">resetear_consentimiento</option>
|
||||||
|
<option value="resetear_toma">resetear_toma</option>
|
||||||
|
<option value="cancelar_toma_pendiente">cancelar_toma_pendiente</option>
|
||||||
|
<option value="vincular_paciente">vincular_paciente</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -104,6 +111,18 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabla -->
|
<!-- Tabla -->
|
||||||
|
<!-- Salud de la voz del televisor: alimentada por turnero_tv_log.
|
||||||
|
Responde de un vistazo «¿la pantalla está llamando bien?» sin
|
||||||
|
tener que consultar la base a mano. -->
|
||||||
|
<div class="card border-0 shadow-sm mb-3" id="card-voz-tv" style="display:none">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<h6 class="mb-2"><i class="fas fa-volume-up text-primary me-1"></i>
|
||||||
|
Voz del televisor — últimos 7 días</h6>
|
||||||
|
<div class="d-flex gap-4 flex-wrap mb-2" id="voz-tv-resumen"></div>
|
||||||
|
<div id="voz-tv-incidentes" class="small"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div id="act-spinner" class="text-center py-5 text-muted">
|
<div id="act-spinner" class="text-center py-5 text-muted">
|
||||||
@@ -270,6 +289,53 @@ function verDetalle(i) {
|
|||||||
new bootstrap.Modal(document.getElementById('modalDetalle')).show();
|
new bootstrap.Modal(document.getElementById('modalDetalle')).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Salud de la voz del televisor ────────────────────────────
|
||||||
|
const VOZ_EVENTO_TXT = {
|
||||||
|
mudo: ['🔇 No sonó', 'text-danger', 'El llamado salió sin voz: ni el reintento habló'],
|
||||||
|
sin_start: ['⚠️ Arrancó con reintento','text-warning','La voz elegida no arrancó; habló la del navegador'],
|
||||||
|
error: ['⚠️ Error del motor', 'text-warning', 'El motor de voz falló a mitad; se reintentó'],
|
||||||
|
lag: ['⏸ Pantalla congelada', 'text-warning', 'La pestaña estuvo oculta o el equipo suspendido: en ese lapso no ve llamados. En el televisor debe estar siempre visible'],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cargarVozTV() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('api/lab/get_tv_log.php');
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.ok) return;
|
||||||
|
// Sin datos todavía (recién desplegado): la tarjeta no aparece
|
||||||
|
if (!d.anuncios_7d && !d.incidentes.length) return;
|
||||||
|
|
||||||
|
document.getElementById('card-voz-tv').style.display = '';
|
||||||
|
const pct = d.anuncios_7d ? Math.round(d.completos_7d / d.anuncios_7d * 100) : 0;
|
||||||
|
document.getElementById('voz-tv-resumen').innerHTML = `
|
||||||
|
<div><span class="fw-bold fs-5">${d.anuncios_7d}</span>
|
||||||
|
<span class="text-muted small">llamados con voz</span></div>
|
||||||
|
<div><span class="fw-bold fs-5 ${pct >= 97 ? 'text-success' : 'text-warning'}">${pct}%</span>
|
||||||
|
<span class="text-muted small">hablados completos</span></div>
|
||||||
|
<div><span class="fw-bold fs-5 ${d.mudos_7d ? 'text-danger' : 'text-success'}">${d.mudos_7d}</span>
|
||||||
|
<span class="text-muted small">salieron mudos</span></div>
|
||||||
|
<div><span class="fw-bold fs-5 ${d.avisos_7d ? 'text-warning' : 'text-success'}">${d.avisos_7d}</span>
|
||||||
|
<span class="text-muted small">se recuperaron con reintento</span></div>`;
|
||||||
|
|
||||||
|
const inc = document.getElementById('voz-tv-incidentes');
|
||||||
|
if (!d.incidentes.length) {
|
||||||
|
inc.innerHTML = '<span class="text-success"><i class="fas fa-check-circle me-1"></i>Sin fallos registrados</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inc.innerHTML = '<div class="text-muted mb-1">Últimos fallos:</div>' +
|
||||||
|
d.incidentes.slice(0, 10).map(i => {
|
||||||
|
const [txt, cls, ayuda] = VOZ_EVENTO_TXT[i.evento] || [i.evento, '', ''];
|
||||||
|
return `<div class="py-1 border-bottom" title="${ayuda}">
|
||||||
|
<span class="text-muted">${(i.cuando || '').slice(0, 16)}</span>
|
||||||
|
· turno <b>${i.turno || '—'}</b>
|
||||||
|
· <span class="${cls}">${txt}</span>
|
||||||
|
${i.voz ? `<span class="text-muted"> (voz: ${i.voz})</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
cargarVozTV();
|
||||||
|
|
||||||
async function exportarCSV() {
|
async function exportarCSV() {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
desde: document.getElementById('act-desde').value,
|
desde: document.getElementById('act-desde').value,
|
||||||
|
|||||||
+35
-3
@@ -472,6 +472,28 @@ function fmtFecha(str) {
|
|||||||
return str.slice(0, 10).split('-').reverse().join('/');
|
return str.slice(0, 10).split('-').reverse().join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quien oculta su número en WhatsApp llega identificado con un BSUID
|
||||||
|
// ("CO.1761088155094242"), que se guarda en phone_number pero no es un teléfono.
|
||||||
|
const esBsuid = v => /^[A-Z]{2}\.\d+$/.test(String(v || ''));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Celda de contacto. Si de esta persona no tenemos teléfono porque lo tiene
|
||||||
|
* oculto, se dice así en vez de mostrar el identificador crudo: recepción
|
||||||
|
* necesita entender por qué no puede llamarla, no ver un código.
|
||||||
|
*/
|
||||||
|
function celdaTelefono(p) {
|
||||||
|
if (esBsuid(p.phone_number)) {
|
||||||
|
return (p.telefono ? esc(p.telefono) : '<span class="text-muted">Sin teléfono</span>')
|
||||||
|
+ '<br><i class="fab fa-whatsapp text-success"></i> '
|
||||||
|
+ '<small class="text-muted" title="Tiene el número oculto en WhatsApp. '
|
||||||
|
+ 'Se le puede escribir por el chat, pero no llamar.">Solo por WhatsApp</small>';
|
||||||
|
}
|
||||||
|
return esc(p.telefono || '—')
|
||||||
|
+ (p.phone_number
|
||||||
|
? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>`
|
||||||
|
: '');
|
||||||
|
}
|
||||||
|
|
||||||
async function cargarLista(pag = 1) {
|
async function cargarLista(pag = 1) {
|
||||||
paginaActual = pag;
|
paginaActual = pag;
|
||||||
const busq = document.getElementById('buscador').value.trim();
|
const busq = document.getElementById('buscador').value.trim();
|
||||||
@@ -495,7 +517,7 @@ async function cargarLista(pag = 1) {
|
|||||||
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
|
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
|
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
|
||||||
<td class="small">${esc(p.telefono||'—')}${p.phone_number ? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>` : ''}</td>
|
<td class="small">${celdaTelefono(p)}</td>
|
||||||
<td class="small text-muted">${esc(p.email||'—')}</td>
|
<td class="small text-muted">${esc(p.email||'—')}</td>
|
||||||
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
||||||
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
||||||
@@ -546,8 +568,18 @@ async function verDetalle(id) {
|
|||||||
document.getElementById('detail-body').innerHTML = `
|
document.getElementById('detail-body').innerHTML = `
|
||||||
<dl class="row small mb-3">
|
<dl class="row small mb-3">
|
||||||
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
|
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
|
||||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
|
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${
|
||||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
|
p.telefono ? esc(p.telefono)
|
||||||
|
: (esBsuid(p.phone_number)
|
||||||
|
? '<span class="text-muted">Sin teléfono — lo tiene oculto en WhatsApp</span>'
|
||||||
|
: '—')
|
||||||
|
}</dd>
|
||||||
|
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${
|
||||||
|
!p.phone_number ? '—'
|
||||||
|
: (esBsuid(p.phone_number)
|
||||||
|
? '<i class="fab fa-whatsapp text-success"></i> Se le puede escribir por el chat, pero no llamar'
|
||||||
|
: `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}`)
|
||||||
|
}</dd>
|
||||||
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
|
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
|
||||||
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
|
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
|
||||||
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
|
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 20260811_bsuid_identidad_whatsapp.sql
|
||||||
|
--
|
||||||
|
-- Meta desplegó los nombres de usuario de WhatsApp: quien oculta su teléfono
|
||||||
|
-- llega al webhook sin `from` ni `wa_id`, identificado solo por su BSUID
|
||||||
|
-- (identificador de usuario por empresa), con la forma "CO.1761088155094242".
|
||||||
|
--
|
||||||
|
-- Ese identificador ocupa el lugar del teléfono en el flujo del bot, pero no
|
||||||
|
-- cabe en varchar(20): los BSUID de Meta llegan a 23 caracteres, así que se
|
||||||
|
-- truncarían en silencio y la persona quedaría imposible de responder.
|
||||||
|
--
|
||||||
|
-- Se amplían a varchar(32) las cuatro tablas por donde circula.
|
||||||
|
-- Las vistas (scheduled_messages_view, v_paciente_resumen) heredan el tipo.
|
||||||
|
-- lab_pacientes.telefono se deja igual: ahí va el teléfono real del paciente,
|
||||||
|
-- y de estas personas justamente no lo tenemos.
|
||||||
|
|
||||||
|
ALTER TABLE users MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||||
|
ALTER TABLE user_states MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||||
|
ALTER TABLE terms_acceptance MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||||
|
ALTER TABLE file_requests MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 20260811_bsuid_mapa_identidad.sql
|
||||||
|
--
|
||||||
|
-- Segunda parte del cambio de identidad de WhatsApp (ver 20260811_bsuid_identidad_whatsapp.sql).
|
||||||
|
--
|
||||||
|
-- Meta manda el BSUID en TODOS los webhooks de mensaje, también en los que aún
|
||||||
|
-- traen teléfono. Eso permite guardar la equivalencia BSUID↔teléfono mientras
|
||||||
|
-- la persona todavía muestra su número, de modo que el día que lo oculte
|
||||||
|
-- siga siendo reconocible: ya sabemos quién es.
|
||||||
|
--
|
||||||
|
-- El histórico de webhook_logs tiene 6.423 equivalencias que se cargan con
|
||||||
|
-- scripts/backfill_bsuid.php.
|
||||||
|
--
|
||||||
|
-- Se guarda en una columna aparte y no en phone_number porque son dos cosas
|
||||||
|
-- distintas: el BSUID identifica, el teléfono además sirve para cruzar con el
|
||||||
|
-- paciente y el turnero. Mezclarlos rompería esos cruces.
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN bsuid VARCHAR(32) NULL DEFAULT NULL COMMENT 'Identificador de usuario por empresa (Meta). Presente aunque la persona oculte su teléfono.' AFTER phone_number;
|
||||||
|
|
||||||
|
-- Único: un BSUID identifica a una sola persona dentro del portafolio.
|
||||||
|
-- Admite varios NULL, que es el caso de todos los usuarios ya existentes.
|
||||||
|
ALTER TABLE users ADD UNIQUE KEY uk_users_bsuid (bsuid);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- 20260811_bsuid_pedir_contacto.sql
|
||||||
|
--
|
||||||
|
-- Tercera parte del cambio de identidad de WhatsApp.
|
||||||
|
--
|
||||||
|
-- A quien oculta su teléfono se le puede pedir con el botón request_contact_info.
|
||||||
|
-- Se registra cuándo se le pidió para no volver a insistirle: pedirle los datos
|
||||||
|
-- una vez es razonable, repetírselo en cada trámite es acoso.
|
||||||
|
--
|
||||||
|
-- Queda NULL para todo el mundo; solo se llena cuando efectivamente se pide.
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN contacto_pedido_at DATETIME NULL DEFAULT NULL
|
||||||
|
COMMENT 'Cuándo se le pidió el teléfono por el botón de WhatsApp. NULL = nunca.'
|
||||||
|
AFTER bsuid;
|
||||||
|
|
||||||
|
-- Texto editable desde configuración, para que el laboratorio ajuste el mensaje
|
||||||
|
-- sin tocar código. Si la fila ya existe, se respeta lo que haya.
|
||||||
|
INSERT INTO system_config (config_key, config_value, description)
|
||||||
|
VALUES (
|
||||||
|
'whatsapp_texto_pedir_contacto',
|
||||||
|
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?',
|
||||||
|
'Mensaje del botón que pide el teléfono a quien lo tiene oculto en WhatsApp'
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE config_key = config_key;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 20260823_tv_voz_log.sql
|
||||||
|
--
|
||||||
|
-- Bitácora de la voz del televisor. Los fallos son intermitentes ("a veces no
|
||||||
|
-- suena, a veces entrecortado") y nadie puede depurarlos mirando la pantalla:
|
||||||
|
-- hay que registrar cada intento de hablar CUANDO ocurre, con qué voz, si
|
||||||
|
-- arrancó, cuánto tardó y cómo terminó. Con esto, la próxima vez que reporten
|
||||||
|
-- "ayer a las 10 no sonó" se consulta esta tabla y se ve exactamente qué pasó.
|
||||||
|
--
|
||||||
|
-- La escribe modules/turnero/api/log_tv.php vía sendBeacon desde la pantalla.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS turnero_tv_log (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
evento VARCHAR(20) NOT NULL COMMENT 'start, end, error, sin_start, mudo',
|
||||||
|
detalle VARCHAR(500) NULL COMMENT 'JSON: código del turno, voz, ms, duración',
|
||||||
|
ip VARCHAR(45) NULL,
|
||||||
|
user_agent VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_evento_fecha (evento, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -233,6 +234,27 @@ try {
|
|||||||
|
|
||||||
notificarSSE((int) $turnoActualizado['sesion_id']);
|
notificarSSE((int) $turnoActualizado['sesion_id']);
|
||||||
|
|
||||||
|
// Queda constancia de quién cambió el estado a mano. Sin esto, en el
|
||||||
|
// historial aparecía un turno cancelado o ausente sin ninguna huella
|
||||||
|
// de quién lo hizo ni desde qué estado.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'cambiar_estado',
|
||||||
|
$turnoId,
|
||||||
|
[
|
||||||
|
'turno_id' => $turnoId,
|
||||||
|
'turno_codigo' => $turnoActualizado['codigo'] ?? null,
|
||||||
|
'estado_previo' => $estadoActual,
|
||||||
|
'estado_nuevo' => $nuevoEstado,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// La auditoría nunca debe tumbar la operación
|
||||||
|
error_log('[cambiar_estado] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
|
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Body: { consentimiento_id }
|
* Body: { consentimiento_id }
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -30,4 +31,35 @@ $stmt->execute([$turnoId, $consentId]);
|
|||||||
|
|
||||||
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
|
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
|
||||||
|
|
||||||
|
// Cancelar una toma pendiente cierra un protocolo que quedó a medias en otra
|
||||||
|
// visita: conviene saber quién lo dio por terminado.
|
||||||
|
try {
|
||||||
|
$inf = $pdo->prepare(
|
||||||
|
"SELECT tc.turno_id, t.codigo, f.nombre AS formulario
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
|
WHERE tc.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$inf->execute([$consentId]);
|
||||||
|
$d = $inf->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'cancelar_toma_pendiente',
|
||||||
|
$consentId,
|
||||||
|
[
|
||||||
|
// Se registra bajo el turno donde se hizo la cancelación, que es
|
||||||
|
// donde alguien la va a buscar en el historial.
|
||||||
|
'turno_id' => $turnoId,
|
||||||
|
'turno_origen_id' => isset($d['turno_id']) ? (int)$d['turno_id'] : null,
|
||||||
|
'turno_origen' => $d['codigo'] ?? null,
|
||||||
|
'formulario_nombre' => $d['formulario'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[cancelar_toma_pendiente] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['cancelado' => true]);
|
jsonOk(['cancelado' => true]);
|
||||||
|
|||||||
@@ -19,9 +19,8 @@ $svg = $body['svg'] ?? '';
|
|||||||
$soloPro = !empty($body['solo_profesional']);
|
$soloPro = !empty($body['solo_profesional']);
|
||||||
// Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
|
// Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
|
||||||
$datosResp = null;
|
$datosResp = null;
|
||||||
if (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']) && !empty($body['datos_respuestas'])) {
|
$datosArr = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
|
||||||
$datosResp = json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE);
|
? $body['datos_respuestas'] : [];
|
||||||
}
|
|
||||||
|
|
||||||
if (!$turnoId) jsonError('turno_id requerido.');
|
if (!$turnoId) jsonError('turno_id requerido.');
|
||||||
if (!$formularioId) jsonError('formulario_id requerido.');
|
if (!$formularioId) jsonError('formulario_id requerido.');
|
||||||
@@ -32,6 +31,32 @@ if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
|
|||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
|
|
||||||
|
// Quién firma se resuelve aquí y no se toma del navegador. El cliente manda el
|
||||||
|
// nombre que cargó al abrir la página, y si el personal cambia de turno sin
|
||||||
|
// recargar, la firma quedaba a nombre de quien abrió el formulario en vez de
|
||||||
|
// quien la está haciendo. La sesión del servidor es la única fuente confiable.
|
||||||
|
// (guardar_toma.php ya lo hacía así para cada toma; esto alinea la firma final.)
|
||||||
|
$__uid = adminId();
|
||||||
|
if ($__uid) {
|
||||||
|
$stmtPro = $pdo->prepare(
|
||||||
|
"SELECT au.full_name, au.cedula AS au_cedula,
|
||||||
|
le.nombre_completo AS pro_nombre, le.numero_documento AS pro_documento
|
||||||
|
FROM admin_users au
|
||||||
|
LEFT JOIN lab_enfermeras le ON le.id = au.enfermera_id
|
||||||
|
WHERE au.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmtPro->execute([$__uid]);
|
||||||
|
$pro = $stmtPro->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
$proNombre = ($pro['pro_nombre'] ?? null) ?: ($pro['full_name'] ?? null);
|
||||||
|
$proCedula = ($pro['au_cedula'] ?? null) ?: ($pro['pro_documento'] ?? null);
|
||||||
|
if ($proNombre) $datosArr['_pro_nombre'] = $proNombre;
|
||||||
|
if ($proCedula) $datosArr['_pro_cedula'] = $proCedula;
|
||||||
|
$datosArr['_pro_id'] = $__uid;
|
||||||
|
}
|
||||||
|
if (!empty($datosArr)) {
|
||||||
|
$datosResp = json_encode($datosArr, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"SELECT tc.id, tc.estado, t.sesion_id
|
"SELECT tc.id, tc.estado, t.sesion_id
|
||||||
FROM turnero_consentimientos tc
|
FROM turnero_consentimientos tc
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
|
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
|
||||||
* - desglose por prioridad
|
* - desglose por prioridad
|
||||||
* - desglose por lugar
|
* - desglose por lugar
|
||||||
|
* - desglose por recepcionista y por bacteriólogo (quién atendió a cuántos)
|
||||||
* - lista de turnos del día (para tabla detalle)
|
* - lista de turnos del día (para tabla detalle)
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
@@ -41,10 +42,12 @@ if (!$sesion) {
|
|||||||
'tiempo_espera_promedio_min' => null,
|
'tiempo_espera_promedio_min' => null,
|
||||||
'tiempo_servicio_promedio_min' => null,
|
'tiempo_servicio_promedio_min' => null,
|
||||||
],
|
],
|
||||||
'por_prioridad' => [],
|
'por_prioridad' => [],
|
||||||
'por_lugar' => [],
|
'por_lugar' => [],
|
||||||
'consent_stats' => [],
|
'por_recepcionista' => [],
|
||||||
'turnos' => [],
|
'por_bacteriologo' => [],
|
||||||
|
'consent_stats' => [],
|
||||||
|
'turnos' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,36 +57,51 @@ $sesionId = (int)$sesion['id'];
|
|||||||
$stmtRes = $pdo->prepare(
|
$stmtRes = $pdo->prepare(
|
||||||
"SELECT
|
"SELECT
|
||||||
COUNT(*) AS total,
|
COUNT(*) AS total,
|
||||||
SUM(estado IN ('finalizado','en_servicio')) AS atendidos,
|
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
|
||||||
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
||||||
SUM(estado = 'ausente') AS ausentes,
|
SUM(t.estado = 'ausente') AS ausentes,
|
||||||
SUM(estado = 'cancelado') AS cancelados,
|
SUM(t.estado = 'cancelado') AS cancelados,
|
||||||
ROUND(
|
ROUND(
|
||||||
AVG(
|
AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL
|
WHEN t.inicio_recepcion_at IS NOT NULL AND t.creado_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0
|
THEN TIMESTAMPDIFF(SECOND, t.creado_at, t.inicio_recepcion_at) / 60.0
|
||||||
END
|
END
|
||||||
), 1
|
), 1
|
||||||
) AS tiempo_espera_promedio_min,
|
) AS tiempo_espera_promedio_min,
|
||||||
|
-- En los protocolos prolongados la atención se corta en la PRIMERA toma:
|
||||||
|
-- lo que sigue son horas de espera del examen (una curva de glucosa son
|
||||||
|
-- 2 horas), no tiempo de trabajo. Contarlas triplicaba el promedio —21,9
|
||||||
|
-- min contra 7,4— y hacía ver los puestos mucho más lentos de lo que son.
|
||||||
ROUND(
|
ROUND(
|
||||||
AVG(
|
AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL
|
WHEN t.inicio_lugar_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
END
|
END
|
||||||
), 1
|
), 1
|
||||||
) AS tiempo_servicio_promedio_min,
|
) AS tiempo_servicio_promedio_min,
|
||||||
ROUND(
|
ROUND(
|
||||||
AVG(
|
AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL
|
WHEN t.creado_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60.0
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.creado_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
END
|
END
|
||||||
), 1
|
), 1
|
||||||
) AS tiempo_total_promedio_min
|
) AS tiempo_total_promedio_min
|
||||||
FROM turnero_turnos
|
FROM turnero_turnos t
|
||||||
WHERE sesion_id = ?"
|
-- Un turno tiene a lo sumo un protocolo prolongado, así que el join no duplica
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||||
|
GROUP BY tc.turno_id
|
||||||
|
) prot ON prot.turno_id = t.id
|
||||||
|
WHERE t.sesion_id = ?"
|
||||||
);
|
);
|
||||||
$stmtRes->execute([$sesionId]);
|
$stmtRes->execute([$sesionId]);
|
||||||
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
|
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
|
||||||
@@ -112,12 +130,20 @@ $stmtLug = $pdo->prepare(
|
|||||||
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
|
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
|
||||||
ROUND(AVG(
|
ROUND(AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL
|
WHEN t.inicio_lugar_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
END
|
END
|
||||||
), 1) AS tiempo_servicio_promedio_min
|
), 1) AS tiempo_servicio_promedio_min
|
||||||
FROM turnero_turnos t
|
FROM turnero_turnos t
|
||||||
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||||
|
GROUP BY tc.turno_id
|
||||||
|
) prot ON prot.turno_id = t.id
|
||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
GROUP BY l.id
|
GROUP BY l.id
|
||||||
ORDER BY l.sort_order ASC, l.nombre ASC"
|
ORDER BY l.sort_order ASC, l.nombre ASC"
|
||||||
@@ -125,6 +151,63 @@ $stmtLug = $pdo->prepare(
|
|||||||
$stmtLug->execute([$sesionId]);
|
$stmtLug->execute([$sesionId]);
|
||||||
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
|
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// ── 4b. Desglose por recepcionista ────────────────────────────
|
||||||
|
// Mismo molde que el ranking de enfermeras de lab_domicilios
|
||||||
|
// (api/lab/get_metricas.php). atendido_recepcion_por guarda al PRIMER
|
||||||
|
// recepcionista que tomó el turno (COALESCE en cambiar_estado.php).
|
||||||
|
$stmtRec = $pdo->prepare(
|
||||||
|
"SELECT u.id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
|
||||||
|
COUNT(t.id) AS total,
|
||||||
|
SUM(t.estado IN ('finalizado','en_servicio','en_espera_lugar')) AS atendidos,
|
||||||
|
SUM(t.estado = 'ausente') AS ausentes,
|
||||||
|
SUM(t.estado = 'cancelado') AS cancelados,
|
||||||
|
ROUND(AVG(
|
||||||
|
CASE
|
||||||
|
WHEN t.fin_recepcion_at IS NOT NULL AND t.inicio_recepcion_at IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60.0
|
||||||
|
END
|
||||||
|
), 1) AS tiempo_promedio_min
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN admin_users u ON u.id = t.atendido_recepcion_por
|
||||||
|
WHERE t.sesion_id = ?
|
||||||
|
GROUP BY u.id, u.full_name, u.username
|
||||||
|
ORDER BY atendidos DESC, total DESC"
|
||||||
|
);
|
||||||
|
$stmtRec->execute([$sesionId]);
|
||||||
|
$porRecepcionista = $stmtRec->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// ── 4c. Desglose por bacteriólogo (quien atendió en el lugar) ──
|
||||||
|
$stmtBac = $pdo->prepare(
|
||||||
|
"SELECT u.id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
|
||||||
|
COUNT(t.id) AS total,
|
||||||
|
SUM(t.estado = 'finalizado') AS finalizados,
|
||||||
|
SUM(t.estado = 'en_servicio') AS en_servicio,
|
||||||
|
SUM(t.estado = 'ausente') AS ausentes,
|
||||||
|
ROUND(AVG(
|
||||||
|
CASE
|
||||||
|
WHEN t.inicio_lugar_at IS NOT NULL
|
||||||
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
|
END
|
||||||
|
), 1) AS tiempo_promedio_min
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN admin_users u ON u.id = t.atendido_lugar_por
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||||
|
GROUP BY tc.turno_id
|
||||||
|
) prot ON prot.turno_id = t.id
|
||||||
|
WHERE t.sesion_id = ?
|
||||||
|
GROUP BY u.id, u.full_name, u.username
|
||||||
|
ORDER BY finalizados DESC, total DESC"
|
||||||
|
);
|
||||||
|
$stmtBac->execute([$sesionId]);
|
||||||
|
$porBacteriologo = $stmtBac->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// ── 5. Lista de turnos del día ────────────────────────────────
|
// ── 5. Lista de turnos del día ────────────────────────────────
|
||||||
$stmtTurnos = $pdo->prepare(
|
$stmtTurnos = $pdo->prepare(
|
||||||
"SELECT t.id, t.codigo, t.numero, t.estado,
|
"SELECT t.id, t.codigo, t.numero, t.estado,
|
||||||
@@ -157,22 +240,47 @@ $stmtTurnos = $pdo->prepare(
|
|||||||
$stmtTurnos->execute([$sesionId]);
|
$stmtTurnos->execute([$sesionId]);
|
||||||
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// Enriquecer con exámenes y comentarios
|
// Enriquecer con exámenes y comentarios — en lote.
|
||||||
foreach ($turnos as &$turno) {
|
// Antes eran 2 consultas por turno dentro del foreach (N+1): con 80 turnos en
|
||||||
|
// el día eran 160 consultas evitables. Mismo patrón que get_historial.php.
|
||||||
|
$turnoIds = array_column($turnos, 'id');
|
||||||
|
$examenesPorTurno = [];
|
||||||
|
$comentariosPorTurno = [];
|
||||||
|
|
||||||
|
if ($turnoIds) {
|
||||||
|
$ph = implode(',', array_fill(0, count($turnoIds), '?'));
|
||||||
|
|
||||||
$stmtEx = $pdo->prepare(
|
$stmtEx = $pdo->prepare(
|
||||||
"SELECT DISTINCT et.nombre FROM turnero_examen_items tei
|
"SELECT DISTINCT ts.turno_id, et.nombre
|
||||||
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
FROM turnero_solicitudes ts
|
||||||
WHERE tei.solicitud_id = (SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1)"
|
JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
|
||||||
|
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||||
|
WHERE ts.turno_id IN ($ph)
|
||||||
|
ORDER BY et.nombre ASC"
|
||||||
);
|
);
|
||||||
$stmtEx->execute([$turno['id']]);
|
$stmtEx->execute($turnoIds);
|
||||||
$turno['examenes'] = $stmtEx->fetchAll(PDO::FETCH_ASSOC);
|
foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$examenesPorTurno[(int)$row['turno_id']][] = ['nombre' => $row['nombre']];
|
||||||
|
}
|
||||||
|
|
||||||
$stmtCom = $pdo->prepare(
|
$stmtCom = $pdo->prepare(
|
||||||
"SELECT usuario_nombre, comentario, tipo, creado_at
|
"SELECT turno_id, usuario_nombre, comentario, tipo, creado_at
|
||||||
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at ASC"
|
FROM turnero_comentarios
|
||||||
|
WHERE turno_id IN ($ph)
|
||||||
|
ORDER BY turno_id, creado_at ASC"
|
||||||
);
|
);
|
||||||
$stmtCom->execute([$turno['id']]);
|
$stmtCom->execute($turnoIds);
|
||||||
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
|
foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$tid = (int)$row['turno_id'];
|
||||||
|
unset($row['turno_id']);
|
||||||
|
$comentariosPorTurno[$tid][] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($turnos as &$turno) {
|
||||||
|
$tid = (int)$turno['id'];
|
||||||
|
$turno['examenes'] = $examenesPorTurno[$tid] ?? [];
|
||||||
|
$turno['comentarios'] = $comentariosPorTurno[$tid] ?? [];
|
||||||
}
|
}
|
||||||
unset($turno);
|
unset($turno);
|
||||||
|
|
||||||
@@ -201,6 +309,8 @@ jsonOk([
|
|||||||
'resumen' => $resumen,
|
'resumen' => $resumen,
|
||||||
'por_prioridad' => $porPrioridad,
|
'por_prioridad' => $porPrioridad,
|
||||||
'por_lugar' => $porLugar,
|
'por_lugar' => $porLugar,
|
||||||
|
'por_recepcionista' => $porRecepcionista,
|
||||||
|
'por_bacteriologo' => $porBacteriologo,
|
||||||
'consent_stats' => $consentStats,
|
'consent_stats' => $consentStats,
|
||||||
'turnos' => $turnos,
|
'turnos' => $turnos,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ if (empty($escritoriosRec)) {
|
|||||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
AND t.estado = 'en_recepcion'
|
AND t.estado = 'en_recepcion'
|
||||||
|
-- Misma regla que en los puestos: el turno vale hasta medianoche
|
||||||
|
AND DATE(t.llamado_recepcion_at) = CURDATE()
|
||||||
ORDER BY t.llamado_recepcion_at DESC"
|
ORDER BY t.llamado_recepcion_at DESC"
|
||||||
);
|
);
|
||||||
$stmtRec->execute([$sesionId]);
|
$stmtRec->execute([$sesionId]);
|
||||||
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
|
|||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
AND t.estado = 'en_servicio'
|
AND t.estado = 'en_servicio'
|
||||||
AND t.lugar_destino_id = ?
|
AND t.lugar_destino_id = ?
|
||||||
|
-- Un turno vale hasta la medianoche de su día. Quedan bastantes sin
|
||||||
|
-- cerrar, y sin este filtro el puesto mostraba como \"llamado ahora\"
|
||||||
|
-- a un paciente de días atrás en cuanto se cerraba el turno real.
|
||||||
|
AND DATE(t.llamado_lugar_at) = CURDATE()
|
||||||
ORDER BY t.llamado_lugar_at DESC
|
ORDER BY t.llamado_lugar_at DESC
|
||||||
LIMIT 1"
|
LIMIT 1"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /modules/turnero/api/get_firma_pendiente.php
|
||||||
|
*
|
||||||
|
* Le dice a la tablet del paciente qué mostrar: si hay alguien siendo atendido
|
||||||
|
* en su puesto y le falta firmar el consentimiento de bienvenida (F-LAB-01).
|
||||||
|
*
|
||||||
|
* SIN SESIÓN DE OPERADOR, a propósito: esta tablet la manipula el público y
|
||||||
|
* nadie va a iniciar sesión en ella cada mañana. Se identifica por la cookie
|
||||||
|
* del dispositivo, que es un token de 64 caracteres registrado en Configuración.
|
||||||
|
*
|
||||||
|
* Por eso devuelve lo mínimo: el turno que está en ese puesto en este instante
|
||||||
|
* y nada más. No permite consultar otros turnos, ni buscar, ni ver historial.
|
||||||
|
* Sin dispositivo reconocido no responde nada.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
|
/** El único formulario que se firma en esta tablet: el de bienvenida. */
|
||||||
|
const FORMULARIO_BIENVENIDA = 17;
|
||||||
|
|
||||||
|
function responder(array $datos): void {
|
||||||
|
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
|
||||||
|
// ── 1. ¿Qué puesto es esta tablet? ────────────────────────────────────────
|
||||||
|
// Solo por token de navegador. La IP no sirve aquí: varias tablets salen por
|
||||||
|
// la misma y acabaríamos mostrándole a un paciente los datos de otro puesto.
|
||||||
|
$token = trim($_COOKIE['turnero_token'] ?? '');
|
||||||
|
if ($token === '') {
|
||||||
|
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT td.lugar_id, td.nombre AS dispositivo, tl.nombre AS lugar, tl.tipo
|
||||||
|
FROM turnero_dispositivos td
|
||||||
|
JOIN turnero_lugares tl ON tl.id = td.lugar_id
|
||||||
|
WHERE td.token = ? AND td.activo = 1
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([$token]);
|
||||||
|
$disp = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$disp || $disp['tipo'] !== 'recepcion') {
|
||||||
|
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. ¿Hay alguien siendo atendido ahí ahora? ────────────────────────────
|
||||||
|
// Mismo criterio que la pantalla del televisor: el turno vale hasta la
|
||||||
|
// medianoche de su día, para no mostrar a un paciente que ya se fue.
|
||||||
|
// La sesión está abierta mientras fin_at siga en nulo; no hay columna de estado.
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT t.id, t.codigo, t.paciente_id, t.paciente_nombre
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN turnero_sesiones s ON s.id = t.sesion_id
|
||||||
|
WHERE t.estado = 'en_recepcion'
|
||||||
|
AND t.recepcion_desk_id = ?
|
||||||
|
AND DATE(t.llamado_recepcion_at) = CURDATE()
|
||||||
|
AND s.fin_at IS NULL
|
||||||
|
ORDER BY t.llamado_recepcion_at DESC
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([(int)$disp['lugar_id']]);
|
||||||
|
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$turno) {
|
||||||
|
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sin paciente vinculado no hay a quién atribuirle la firma
|
||||||
|
if (empty($turno['paciente_id'])) {
|
||||||
|
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. ¿Le falta firmar el consentimiento de bienvenida? ──────────────────
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT token, estado FROM turnero_consentimientos
|
||||||
|
WHERE turno_id = ? AND formulario_id = ?
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
|
||||||
|
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($consent && in_array($consent['estado'], ['firmado', 'rechazado'], true)) {
|
||||||
|
responder([
|
||||||
|
'ok' => true,
|
||||||
|
'estado' => 'firmado',
|
||||||
|
'turno_id' => (int)$turno['id'],
|
||||||
|
'codigo' => $turno['codigo'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si el consentimiento todavía no existe se crea aquí. Es lo que permite que
|
||||||
|
// la tablet aparezca sola, sin que la recepcionista tenga que mandarlo.
|
||||||
|
if (!$consent) {
|
||||||
|
$tokenFirma = sprintf(
|
||||||
|
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000,
|
||||||
|
mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
$ins = $pdo->prepare(
|
||||||
|
"INSERT IGNORE INTO turnero_consentimientos
|
||||||
|
(turno_id, formulario_id, token, estado, creado_por)
|
||||||
|
VALUES (?, ?, ?, 'pendiente', NULL)"
|
||||||
|
);
|
||||||
|
$ins->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA, $tokenFirma]);
|
||||||
|
|
||||||
|
// INSERT IGNORE puede no haber insertado si otra petición se adelantó:
|
||||||
|
// se relee para quedarse con el token que realmente quedó guardado.
|
||||||
|
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
|
||||||
|
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$consent) {
|
||||||
|
responder(['ok' => false, 'motivo' => 'no_se_pudo_crear']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responder([
|
||||||
|
'ok' => true,
|
||||||
|
'estado' => 'por_firmar',
|
||||||
|
'turno_id' => (int)$turno['id'],
|
||||||
|
'codigo' => $turno['codigo'],
|
||||||
|
'paciente' => $turno['paciente_nombre'],
|
||||||
|
'url' => BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($consent['token']),
|
||||||
|
]);
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* GET Historial paginado de turnos con filtros multi-día.
|
* GET Historial paginado de turnos con filtros multi-día.
|
||||||
*
|
*
|
||||||
* Parámetros (todos opcionales):
|
* Parámetros (todos opcionales):
|
||||||
* fecha_desde YYYY-MM-DD (default: hace 7 días)
|
* fecha_desde YYYY-MM-DD (default: hoy; hace 12 meses si se pasa paciente_id)
|
||||||
* fecha_hasta YYYY-MM-DD (default: hoy)
|
* fecha_hasta YYYY-MM-DD (default: hoy)
|
||||||
* estado string ("finalizado","ausente","cancelado","en_servicio",... o vacío = todos)
|
* estado string ("finalizado","ausente","cancelado","en_servicio",... o vacío = todos)
|
||||||
* lugar_id int (0 = todos)
|
* lugar_id int (0 = todos)
|
||||||
@@ -23,16 +23,29 @@ $pdo = db();
|
|||||||
// ── Parámetros ────────────────────────────────────────────────
|
// ── Parámetros ────────────────────────────────────────────────
|
||||||
$hoy = date('Y-m-d');
|
$hoy = date('Y-m-d');
|
||||||
|
|
||||||
|
// paciente_id se lee primero: determina el rango de fechas por defecto.
|
||||||
|
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
|
||||||
|
|
||||||
$fechaDesde = trim($_GET['fecha_desde'] ?? '');
|
$fechaDesde = trim($_GET['fecha_desde'] ?? '');
|
||||||
$fechaHasta = trim($_GET['fecha_hasta'] ?? '');
|
$fechaHasta = trim($_GET['fecha_hasta'] ?? '');
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $hoy;
|
|
||||||
|
// Rango por defecto cuando el cliente no manda fechas:
|
||||||
|
// - Ficha de paciente (paciente_id): ultimos 12 meses.
|
||||||
|
// Antes caia en "hoy", asi que el historial del paciente solo podia mostrar
|
||||||
|
// visitas del mismo dia y siempre respondia "sin visitas anteriores".
|
||||||
|
// - Listado general del historial: hoy (comportamiento original).
|
||||||
|
// Para ver el historial completo, el cliente pasa fecha_desde explicita.
|
||||||
|
$desdePorDefecto = $pacienteId > 0
|
||||||
|
? date('Y-m-d', strtotime('-12 months'))
|
||||||
|
: $hoy;
|
||||||
|
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $desdePorDefecto;
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaHasta)) $fechaHasta = $hoy;
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaHasta)) $fechaHasta = $hoy;
|
||||||
if ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
|
if ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
|
||||||
|
|
||||||
$estadoFiltro = trim($_GET['estado'] ?? '');
|
$estadoFiltro = trim($_GET['estado'] ?? '');
|
||||||
$lugarId = (int)($_GET['lugar_id'] ?? 0);
|
$lugarId = (int)($_GET['lugar_id'] ?? 0);
|
||||||
$prioridadId = (int)($_GET['prioridad_id'] ?? 0);
|
$prioridadId = (int)($_GET['prioridad_id'] ?? 0);
|
||||||
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
|
|
||||||
$q = trim($_GET['q'] ?? '');
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
|
||||||
$perPage = min(200, max(10, (int)($_GET['per_page'] ?? 50)));
|
$perPage = min(200, max(10, (int)($_GET['per_page'] ?? 50)));
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /modules/turnero/api/get_traza_turno.php?turno_id=N
|
||||||
|
*
|
||||||
|
* Bitácora de un turno: qué pasó, cuándo y quién lo hizo.
|
||||||
|
*
|
||||||
|
* La información ya existía, pero repartida en cinco tablas y sin ninguna
|
||||||
|
* pantalla que la juntara: para saber quién retiró un formulario había que
|
||||||
|
* consultar la base a mano. Aquí se unifica en una sola línea de tiempo.
|
||||||
|
*
|
||||||
|
* No inventa nada: cada evento sale de un dato guardado. Lo que el sistema no
|
||||||
|
* registró —ver `sinRastro` más abajo— simplemente no aparece.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireMethod('GET');
|
||||||
|
requireTurnero();
|
||||||
|
|
||||||
|
$turnoId = (int)($_GET['turno_id'] ?? 0);
|
||||||
|
if (!$turnoId) jsonError('turno_id requerido');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$t = $pdo->prepare(
|
||||||
|
"SELECT t.*, l.nombre AS lugar_nombre, d.nombre AS desk_nombre,
|
||||||
|
ur.full_name AS recepcionista, ul.full_name AS profesional
|
||||||
|
FROM turnero_turnos t
|
||||||
|
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||||
|
LEFT JOIN turnero_lugares d ON d.id = t.recepcion_desk_id
|
||||||
|
LEFT JOIN admin_users ur ON ur.id = t.atendido_recepcion_por
|
||||||
|
LEFT JOIN admin_users ul ON ul.id = t.atendido_lugar_por
|
||||||
|
WHERE t.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$t->execute([$turnoId]);
|
||||||
|
$turno = $t->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$turno) jsonError('Turno no encontrado', 404);
|
||||||
|
|
||||||
|
$ev = [];
|
||||||
|
$add = function ($cuando, $tipo, $texto, $quien = null, $extra = null) use (&$ev) {
|
||||||
|
if (!$cuando) return;
|
||||||
|
$ev[] = [
|
||||||
|
'cuando' => $cuando,
|
||||||
|
'tipo' => $tipo, // la vista lo usa para el ícono y el color
|
||||||
|
'texto' => $texto,
|
||||||
|
'quien' => $quien ?: null,
|
||||||
|
'extra' => $extra ?: null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 1. Recorrido del turno ────────────────────────────────────────────────
|
||||||
|
$add($turno['creado_at'], 'turno', 'Turno creado');
|
||||||
|
$add($turno['llamado_recepcion_at'], 'llamado', 'Llamado a ' . ($turno['desk_nombre'] ?: 'recepción'));
|
||||||
|
$add($turno['inicio_recepcion_at'], 'atencion', 'Inicia atención en recepción', $turno['recepcionista']);
|
||||||
|
$add($turno['fin_recepcion_at'], 'atencion', 'Termina recepción');
|
||||||
|
$add($turno['llamado_lugar_at'], 'llamado', 'Llamado a ' . ($turno['lugar_nombre'] ?: 'toma de muestras'));
|
||||||
|
$add($turno['inicio_lugar_at'], 'atencion', 'Inicia toma de muestras', $turno['profesional']);
|
||||||
|
$add($turno['muestra_espera_at'], 'muestra', 'Pasa a espera de muestra');
|
||||||
|
$add($turno['fin_lugar_at'], 'fin', 'Finaliza la atención');
|
||||||
|
|
||||||
|
// ── 2. Documentos ─────────────────────────────────────────────────────────
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT tc.enviado_at, tc.firmado_at, tc.firmado_profesional_at, tc.estado,
|
||||||
|
tc.firma_svg, tc.firma_profesional_svg, tc.datos_respuestas,
|
||||||
|
f.nombre AS formulario, f.es_toma_progresiva
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
|
WHERE tc.turno_id = ?"
|
||||||
|
);
|
||||||
|
$q->execute([$turnoId]);
|
||||||
|
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $c) {
|
||||||
|
$add($c['enviado_at'], 'doc', 'Enviado: ' . $c['formulario']);
|
||||||
|
|
||||||
|
// No todos los formularios los firma el paciente: el F-LAB-08 solo lo firma
|
||||||
|
// el profesional y el F-LAB-28 lleva una firma por toma. Se mira qué firma
|
||||||
|
// hay guardada en vez de suponer que firmado_at es del paciente, que era lo
|
||||||
|
// que antes hacía decir «firmado por el paciente» en documentos que el
|
||||||
|
// paciente nunca firmó.
|
||||||
|
$firmoPaciente = !empty($c['firma_svg']);
|
||||||
|
$firmoProfesional = !empty($c['firma_profesional_svg']);
|
||||||
|
|
||||||
|
if ($firmoPaciente) {
|
||||||
|
$add($c['firmado_at'], 'doc_ok', 'Firmado por el paciente: ' . $c['formulario']);
|
||||||
|
}
|
||||||
|
if ($firmoProfesional) {
|
||||||
|
$add($c['firmado_profesional_at'] ?: $c['firmado_at'], 'doc_ok',
|
||||||
|
'Firmado por el profesional: ' . $c['formulario']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocolo prolongado: cada toma lleva su hora y su firmante, que es el
|
||||||
|
// dato que de verdad interesa. La firma general no dice quién pinchó.
|
||||||
|
if (!empty($c['es_toma_progresiva'])) {
|
||||||
|
$dr = json_decode($c['datos_respuestas'] ?? '{}', true) ?: [];
|
||||||
|
$tomas = [];
|
||||||
|
foreach ($dr as $clave => $valor) {
|
||||||
|
// Las firmas de toma terminan en _f y traen su hora en _h
|
||||||
|
if (!preg_match('/^(_tm[a-z0-9]+)_f$/', $clave, $m)) continue;
|
||||||
|
if (!is_string($valor) || strlen($valor) < 10) continue;
|
||||||
|
$base = $m[1];
|
||||||
|
$tomas[] = [
|
||||||
|
'hora' => $dr[$base . '_h'] ?? null,
|
||||||
|
// _tm00 → «Minuto 0», _tm150 → «Minuto 150»
|
||||||
|
'etiqueta' => 'Minuto ' . (int) substr($base, 3),
|
||||||
|
'firmante' => $dr[$clave . '_pro_nombre'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Se ordenan por hora para que la línea de tiempo quede coherente
|
||||||
|
usort($tomas, fn($a, $b) => strcmp((string)$a['hora'], (string)$b['hora']));
|
||||||
|
foreach ($tomas as $t) {
|
||||||
|
if (!$t['hora']) continue;
|
||||||
|
// La hora viene como "HH:MM"; se ancla al día del turno
|
||||||
|
$cuando = substr($turno['creado_at'], 0, 10) . ' ' . $t['hora'] . ':00';
|
||||||
|
$add($cuando, 'muestra',
|
||||||
|
'Toma tomada y firmada (' . $t['etiqueta'] . ')',
|
||||||
|
$t['firmante']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$firmoPaciente && !$firmoProfesional && $c['firmado_at'] && empty($c['es_toma_progresiva'])) {
|
||||||
|
$add($c['firmado_at'], 'doc_ok', 'Completado: ' . $c['formulario']);
|
||||||
|
}
|
||||||
|
if (!empty($c['es_toma_progresiva']) && $c['firmado_at']) {
|
||||||
|
$add($c['firmado_at'], 'fin', 'Protocolo cerrado: ' . $c['formulario']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Comentarios del personal ───────────────────────────────────────────
|
||||||
|
// Aquí caen también las ausencias, que se guardan como comentario con su motivo.
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT creado_at, usuario_nombre, comentario, tipo
|
||||||
|
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at"
|
||||||
|
);
|
||||||
|
$q->execute([$turnoId]);
|
||||||
|
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $c) {
|
||||||
|
$add($c['creado_at'], 'comentario', $c['comentario'], $c['usuario_nombre'], $c['tipo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Muestras ───────────────────────────────────────────────────────────
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT m.recibida_at, m.tipo_muestra, m.estado, m.motivo_rechazo,
|
||||||
|
u.full_name AS quien
|
||||||
|
FROM turnero_muestras m
|
||||||
|
LEFT JOIN admin_users u ON u.id = m.recibida_por
|
||||||
|
WHERE m.recibida_en_turno_id = ?"
|
||||||
|
);
|
||||||
|
$q->execute([$turnoId]);
|
||||||
|
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $m) {
|
||||||
|
$texto = $m['estado'] === 'rechazada'
|
||||||
|
? 'Muestra RECHAZADA (' . $m['tipo_muestra'] . '): ' . ($m['motivo_rechazo'] ?: 'sin motivo')
|
||||||
|
: 'Muestra recibida (' . $m['tipo_muestra'] . ')';
|
||||||
|
$add($m['recibida_at'], $m['estado'] === 'rechazada' ? 'alerta' : 'muestra', $texto, $m['quien']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Acciones administrativas auditadas ─────────────────────────────────
|
||||||
|
// El detalle es JSON; se filtra por turno en PHP para no depender de LIKE.
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT created_at, admin_nombre, accion, detalle
|
||||||
|
FROM lab_actividad_admin
|
||||||
|
WHERE modulo IN ('turnero','turnero_config') AND detalle LIKE ?"
|
||||||
|
);
|
||||||
|
$q->execute(['%"turno_id":' . $turnoId . '%']);
|
||||||
|
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||||
|
$d = json_decode($a['detalle'], true) ?: [];
|
||||||
|
if ((int)($d['turno_id'] ?? 0) !== $turnoId) continue; // evita coincidencias por prefijo
|
||||||
|
|
||||||
|
// Cada acción se traduce a algo que se entienda leyéndolo, no al nombre
|
||||||
|
// técnico del endpoint.
|
||||||
|
switch ($a['accion']) {
|
||||||
|
case 'quitar_consentimiento':
|
||||||
|
$add($a['created_at'], 'alerta',
|
||||||
|
'Formulario RETIRADO: ' . ($d['formulario_nombre'] ?? '?'),
|
||||||
|
$a['admin_nombre'],
|
||||||
|
'Motivo: ' . ($d['motivo'] ?? 'sin motivo')
|
||||||
|
. ' · estaba en «' . ($d['estado_previo'] ?? '?') . '»');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cambiar_estado':
|
||||||
|
$add($a['created_at'], 'admin',
|
||||||
|
'Estado cambiado a mano: ' . ($d['estado_previo'] ?? '?')
|
||||||
|
. ' → ' . ($d['estado_nuevo'] ?? '?'),
|
||||||
|
$a['admin_nombre']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'resetear_consentimiento':
|
||||||
|
$add($a['created_at'], 'alerta',
|
||||||
|
'Formulario REINICIADO: ' . ($d['formulario_nombre'] ?? '?'),
|
||||||
|
$a['admin_nombre'],
|
||||||
|
!empty($d['tenia_firma'])
|
||||||
|
? 'Se descartó una firma ya hecha'
|
||||||
|
: 'No tenía firma todavía');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'resetear_toma':
|
||||||
|
$n = is_array($d['campos_olvidados'] ?? null) ? count($d['campos_olvidados']) : 0;
|
||||||
|
$add($a['created_at'], 'alerta',
|
||||||
|
'Toma olvidada en ' . ($d['formulario_nombre'] ?? 'protocolo'),
|
||||||
|
$a['admin_nombre'],
|
||||||
|
$n ? "Se descartaron $n dato(s) de la toma" : null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cancelar_toma_pendiente':
|
||||||
|
$add($a['created_at'], 'alerta',
|
||||||
|
'Cancelada la toma pendiente de ' . ($d['turno_origen'] ?? 'otra visita'),
|
||||||
|
$a['admin_nombre'],
|
||||||
|
$d['formulario_nombre'] ?? null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'llamar_turno':
|
||||||
|
$add($a['created_at'], 'llamado', 'Llamó al paciente', $a['admin_nombre']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rellamar':
|
||||||
|
$add($a['created_at'], 'llamado', 'Volvió a llamar al paciente', $a['admin_nombre']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'vincular_paciente':
|
||||||
|
$add($a['created_at'], 'admin',
|
||||||
|
'Paciente vinculado: ' . ($d['paciente_nombre'] ?? '#' . ($d['paciente_id'] ?? '?')),
|
||||||
|
$a['admin_nombre']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$add($a['created_at'], 'admin', $a['accion'], $a['admin_nombre']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($ev, fn($a, $b) => strcmp($a['cuando'], $b['cuando']));
|
||||||
|
|
||||||
|
// ── 6. Conversación de WhatsApp ───────────────────────────────────────────
|
||||||
|
// Solo el conteo del día y el enlace al chat: volcar los mensajes aquí sería
|
||||||
|
// esparcir datos personales del paciente por una pantalla de consulta.
|
||||||
|
$chat = null;
|
||||||
|
if (!empty($turno['paciente_id'])) {
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT u.id AS user_id,
|
||||||
|
(SELECT COUNT(*) FROM conversations c
|
||||||
|
WHERE c.user_id = u.id AND DATE(c.created_at) = DATE(?)) AS mensajes
|
||||||
|
FROM lab_pacientes p JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$q->execute([$turno['creado_at'], (int)$turno['paciente_id']]);
|
||||||
|
$r = $q->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($r && (int)$r['mensajes'] > 0) {
|
||||||
|
$chat = ['user_id' => (int)$r['user_id'], 'mensajes' => (int)$r['mensajes']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOk([
|
||||||
|
'turno' => [
|
||||||
|
'id' => (int)$turno['id'],
|
||||||
|
'codigo' => $turno['codigo'],
|
||||||
|
'estado' => $turno['estado'],
|
||||||
|
'paciente' => $turno['paciente_nombre'],
|
||||||
|
],
|
||||||
|
'eventos' => $ev,
|
||||||
|
'chat' => $chat,
|
||||||
|
// Se declara lo que el sistema NO registra, para que nadie lea la ausencia
|
||||||
|
// de un evento como prueba de que no ocurrió.
|
||||||
|
// Lo que el sistema sigue sin registrar. Se declara para que la ausencia de
|
||||||
|
// un evento no se lea como prueba de que no ocurrió.
|
||||||
|
'sinRastro' => [
|
||||||
|
'el llamado original de los turnos anteriores a hoy, que el rellamado sobrescribía',
|
||||||
|
],
|
||||||
|
]);
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -144,6 +145,20 @@ try {
|
|||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
notificarSSE((int) $turnoActualizado['sesion_id']);
|
notificarSSE((int) $turnoActualizado['sesion_id']);
|
||||||
|
|
||||||
|
// Quién llamó no se guardaba en ninguna parte: el turno solo conserva la hora
|
||||||
|
// del último llamado, así que en la bitácora aparecía «Llamado a X» sin autor.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(), 'turnero', 'llamar_turno', $turnoId,
|
||||||
|
[
|
||||||
|
'turno_id' => $turnoId,
|
||||||
|
'turno_codigo' => $turnoActualizado['codigo'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[llamar_turno] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado");
|
jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado");
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /modules/turnero/api/log_tv.php
|
||||||
|
* Recibe la bitácora de voz de la pantalla del televisor (via sendBeacon).
|
||||||
|
*
|
||||||
|
* SIN SESIÓN, a propósito: la pantalla es un kiosco público (display_global es
|
||||||
|
* ruta pública) y el envío es fire-and-forget. Por eso el endpoint es
|
||||||
|
* deliberadamente sordo y estrecho:
|
||||||
|
* - solo acepta los eventos de la lista, nada libre;
|
||||||
|
* - recorta el detalle a 500 caracteres;
|
||||||
|
* - no devuelve datos, solo un 204;
|
||||||
|
* - inserta y nada más: no lee, no borra, no actualiza.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
|
http_response_code(204);
|
||||||
|
header('Content-Type: text/plain');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') exit;
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw === false || strlen($raw) > 2000) exit;
|
||||||
|
|
||||||
|
$d = json_decode($raw, true);
|
||||||
|
if (!is_array($d)) exit;
|
||||||
|
|
||||||
|
$evento = $d['e'] ?? '';
|
||||||
|
$PERMITIDOS = ['start', 'end', 'error', 'sin_start', 'mudo', 'lag'];
|
||||||
|
if (!in_array($evento, $PERMITIDOS, true)) exit;
|
||||||
|
|
||||||
|
unset($d['e'], $d['t']); // el timestamp lo pone la base, no el cliente
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"INSERT INTO turnero_tv_log (evento, detalle, ip, user_agent) VALUES (?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$evento,
|
||||||
|
mb_substr(json_encode($d, JSON_UNESCAPED_UNICODE), 0, 500),
|
||||||
|
substr(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]), 0, 45),
|
||||||
|
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// La bitácora jamás debe afectar a la pantalla
|
||||||
|
error_log('[log_tv] ' . $e->getMessage());
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -48,4 +49,15 @@ $stmtT = $pdo->prepare(
|
|||||||
$stmtT->execute([$turnoId]);
|
$stmtT->execute([$turnoId]);
|
||||||
$turno = $stmtT->fetch(PDO::FETCH_ASSOC);
|
$turno = $stmtT->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// El rellamado pisa llamado_recepcion_at, así que sin esto no queda rastro de
|
||||||
|
// cuántas veces se llamó al paciente ni quién lo hizo.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(), 'turnero', 'rellamar', $turnoId,
|
||||||
|
['turno_id' => $turnoId, 'turno_codigo' => $turno['codigo'] ?? null]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[rellamar] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['turno' => $turno]);
|
jsonOk(['turno' => $turno]);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Body JSON: { token: string }
|
* Body JSON: { token: string }
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -14,9 +15,11 @@ if (!$token) jsonError('token requerido.');
|
|||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"SELECT tc.id, t.sesion_id
|
"SELECT tc.id, tc.turno_id, tc.estado, tc.firmado_at, t.sesion_id, t.codigo,
|
||||||
|
f.nombre AS formulario
|
||||||
FROM turnero_consentimientos tc
|
FROM turnero_consentimientos tc
|
||||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
WHERE tc.token = ? LIMIT 1"
|
WHERE tc.token = ? LIMIT 1"
|
||||||
);
|
);
|
||||||
$stmt->execute([$token]);
|
$stmt->execute([$token]);
|
||||||
@@ -34,4 +37,25 @@ $pdo->prepare(
|
|||||||
)->execute([$token]);
|
)->execute([$token]);
|
||||||
|
|
||||||
notificarSSE((int)$tc['sesion_id']);
|
notificarSSE((int)$tc['sesion_id']);
|
||||||
|
|
||||||
|
// Esta acción descarta una firma ya hecha, así que tiene que dejar constancia:
|
||||||
|
// se anota qué documento era, en qué estado estaba y si tenía firma.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'resetear_consentimiento',
|
||||||
|
(int)$tc['id'],
|
||||||
|
[
|
||||||
|
'turno_id' => (int)$tc['turno_id'],
|
||||||
|
'turno_codigo' => $tc['codigo'],
|
||||||
|
'formulario_nombre' => $tc['formulario'],
|
||||||
|
'estado_previo' => $tc['estado'],
|
||||||
|
'tenia_firma' => !empty($tc['firmado_at']),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[resetear_consentimiento] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk([], 'Consentimiento reiniciado correctamente.');
|
jsonOk([], 'Consentimiento reiniciado correctamente.');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Body JSON: { token: string, campos: string[] }
|
* Body JSON: { token: string, campos: string[] }
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -17,9 +18,11 @@ if (!is_array($campos) || empty($campos)) jsonError('campos requerido.');
|
|||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"SELECT tc.id, tc.datos_respuestas, tc.estado, t.sesion_id
|
"SELECT tc.id, tc.turno_id, tc.datos_respuestas, tc.estado, t.sesion_id, t.codigo,
|
||||||
|
f.nombre AS formulario
|
||||||
FROM turnero_consentimientos tc
|
FROM turnero_consentimientos tc
|
||||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
WHERE tc.token = ? LIMIT 1"
|
WHERE tc.token = ? LIMIT 1"
|
||||||
);
|
);
|
||||||
$stmt->execute([$token]);
|
$stmt->execute([$token]);
|
||||||
@@ -40,4 +43,24 @@ $pdo->prepare(
|
|||||||
)->execute([json_encode($dr, JSON_UNESCAPED_UNICODE), $token]);
|
)->execute([json_encode($dr, JSON_UNESCAPED_UNICODE), $token]);
|
||||||
|
|
||||||
notificarSSE((int)$tc['sesion_id']);
|
notificarSSE((int)$tc['sesion_id']);
|
||||||
|
|
||||||
|
// Olvidar una toma borra datos de un protocolo prolongado en curso: se registra
|
||||||
|
// qué campos se descartaron, porque después no hay forma de saberlo.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'resetear_toma',
|
||||||
|
(int)$tc['id'],
|
||||||
|
[
|
||||||
|
'turno_id' => (int)$tc['turno_id'],
|
||||||
|
'turno_codigo' => $tc['codigo'],
|
||||||
|
'formulario_nombre' => $tc['formulario'],
|
||||||
|
'campos_olvidados' => $campos,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[resetear_toma] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk([], 'Toma olvidada.');
|
jsonOk([], 'Toma olvidada.');
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ header('Cache-Control: no-store, no-cache');
|
|||||||
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
|
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
|
||||||
header('Connection: keep-alive');
|
header('Connection: keep-alive');
|
||||||
|
|
||||||
|
// El arranque del ERP deja un búfer de salida activo (los jsonOk() de
|
||||||
|
// _helpers hacen ob_clean(), que lo confirma). flush() a secas no lo
|
||||||
|
// atraviesa: TODO lo emitido quedaba atrapado y el cliente recibía cero
|
||||||
|
// bytes, para siempre. Por eso este endpoint parecía muerto en producción.
|
||||||
|
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||||
|
@ob_implicit_flush(true);
|
||||||
|
|
||||||
// ── Parámetros ────────────────────────────────────────────────
|
// ── Parámetros ────────────────────────────────────────────────
|
||||||
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
|
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
|
||||||
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
|
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Vincula un paciente al turno para que los consentimientos puedan crearse.
|
* Vincula un paciente al turno para que los consentimientos puedan crearse.
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -27,4 +28,22 @@ $stmt->execute([$pacienteId, $nombreCompleto, $turnoId]);
|
|||||||
|
|
||||||
if (!$stmt->rowCount()) jsonError('Turno no encontrado.', 404);
|
if (!$stmt->rowCount()) jsonError('Turno no encontrado.', 404);
|
||||||
|
|
||||||
|
// Vincular a la persona equivocada manda la atención a otra historia clínica,
|
||||||
|
// así que queda registrado quién hizo la vinculación.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'vincular_paciente',
|
||||||
|
$turnoId,
|
||||||
|
[
|
||||||
|
'turno_id' => $turnoId,
|
||||||
|
'paciente_id' => $pacienteId,
|
||||||
|
'paciente_nombre' => $nombreCompleto,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[vincular_paciente] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['turno_id' => $turnoId, 'paciente_id' => $pacienteId]);
|
jsonOk(['turno_id' => $turnoId, 'paciente_id' => $pacienteId]);
|
||||||
|
|||||||
@@ -251,6 +251,9 @@ Layout::open('Bandeja del día', 'fas fa-layer-group');
|
|||||||
.bnd-com-user { font-weight: 600; color: #1e293b; }
|
.bnd-com-user { font-weight: 600; color: #1e293b; }
|
||||||
.bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; }
|
.bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; }
|
||||||
.bnd-com-text { color: #334155; }
|
.bnd-com-text { color: #334155; }
|
||||||
|
.bnd-traza { border-left:2px solid #e2e8f0; margin-left:4px; padding-left:10px; }
|
||||||
|
.bnd-traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.8rem; color:#334155; }
|
||||||
|
.bnd-traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
|
||||||
/* Agregar comentario */
|
/* Agregar comentario */
|
||||||
.bnd-add-com { display: flex; gap: 8px; align-items: flex-end; }
|
.bnd-add-com { display: flex; gap: 8px; align-items: flex-end; }
|
||||||
.bnd-add-com textarea {
|
.bnd-add-com textarea {
|
||||||
@@ -702,7 +705,50 @@ function _renderDetalle(d) {
|
|||||||
<button onclick="_bndEnviarCom(${t.id})"><i class="fas fa-paper-plane"></i> Enviar</button>
|
<button onclick="_bndEnviarCom(${t.id})"><i class="fas fa-paper-plane"></i> Enviar</button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
|
|
||||||
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom;
|
const secTraza = `<div class="bnd-sec">
|
||||||
|
<div class="bnd-sec-title"><i class="fas fa-clock-rotate-left"></i> Qué pasó con este turno</div>
|
||||||
|
<div id="bnd-traza"><span style="color:#94a3b8;font-size:.82rem">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> Cargando…</span></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom + secTraza;
|
||||||
|
_bndCargarTraza(t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bitácora del turno ────────────────────────────────────────
|
||||||
|
// Los comentarios ya salen arriba; aquí va el recorrido completo, incluidas las
|
||||||
|
// acciones administrativas (por ejemplo el retiro de un formulario, con motivo).
|
||||||
|
const BND_TRAZA_ICO = {
|
||||||
|
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
|
||||||
|
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
|
||||||
|
doc_ok:'fa-file-signature', comentario:'fa-comment',
|
||||||
|
alerta:'fa-triangle-exclamation', admin:'fa-user-shield',
|
||||||
|
};
|
||||||
|
const BND_TRAZA_COLOR = { alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb', fin:'#0f766e', admin:'#7c3aed' };
|
||||||
|
|
||||||
|
async function _bndCargarTraza(turnoId) {
|
||||||
|
const cont = document.getElementById('bnd-traza');
|
||||||
|
if (!cont) return;
|
||||||
|
const esc = x => String(x ?? '').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||||
|
try {
|
||||||
|
const j = await fetch(`modules/turnero/api/get_traza_turno.php?turno_id=${turnoId}`).then(r=>r.json());
|
||||||
|
if (!j.ok || !j.eventos?.length) {
|
||||||
|
cont.innerHTML = '<span style="color:#94a3b8;font-size:.82rem">Sin registros</span>'; return;
|
||||||
|
}
|
||||||
|
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
|
||||||
|
cont.innerHTML = '<div class="bnd-traza">' + j.eventos.map(e => `
|
||||||
|
<div class="bnd-traza-fila">
|
||||||
|
<span class="bnd-traza-hora">${hora(e.cuando)}</span>
|
||||||
|
<i class="fas ${BND_TRAZA_ICO[e.tipo]||'fa-circle'}"
|
||||||
|
style="color:${BND_TRAZA_COLOR[e.tipo]||'#94a3b8'};width:14px;font-size:.72rem;margin-top:2px"></i>
|
||||||
|
<div style="flex:1">${esc(e.texto)}
|
||||||
|
${e.quien ? `<span style="color:#64748b;font-style:italic"> ${esc(e.quien)}</span>` : ''}
|
||||||
|
${e.extra ? `<div style="color:#b45309;font-size:.74rem">${esc(e.extra)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`).join('') + '</div>';
|
||||||
|
} catch (_) {
|
||||||
|
cont.innerHTML = '<span style="color:#94a3b8;font-size:.82rem">Error al cargar</span>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Marcar visto ──────────────────────────────────────────────
|
// ── Marcar visto ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -578,6 +578,10 @@ let state = {
|
|||||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadContacts();
|
loadContacts();
|
||||||
|
// Permite llegar directo a una conversación desde otra pantalla, por ejemplo
|
||||||
|
// desde la bitácora del turno en el historial: ?v=chat&user_id=123
|
||||||
|
const _uid = parseInt(new URLSearchParams(location.search).get('user_id') || '', 10);
|
||||||
|
if (_uid > 0) openChat(_uid);
|
||||||
document.getElementById('searchInput').addEventListener('input', debounce(() => {
|
document.getElementById('searchInput').addEventListener('input', debounce(() => {
|
||||||
loadContacts(document.getElementById('searchInput').value.trim());
|
loadContacts(document.getElementById('searchInput').value.trim());
|
||||||
}, 300));
|
}, 300));
|
||||||
|
|||||||
@@ -1160,10 +1160,13 @@ $tab = $_GET['tab'] ?? 'lugares';
|
|||||||
<div class="tv-media-item d-flex align-items-center gap-3 p-2" data-id="<?= (int)$m['id'] ?>"
|
<div class="tv-media-item d-flex align-items-center gap-3 p-2" data-id="<?= (int)$m['id'] ?>"
|
||||||
style="border:1px solid #e2e8f0;border-radius:10px">
|
style="border:1px solid #e2e8f0;border-radius:10px">
|
||||||
<i class="fas fa-grip-vertical text-muted" style="cursor:grab"></i>
|
<i class="fas fa-grip-vertical text-muted" style="cursor:grab"></i>
|
||||||
|
<?php /* Miniatura cuadrada, igual que la pantalla del televisor: con la
|
||||||
|
antigua de 90x60 el material cuadrado se veía recortado aquí y
|
||||||
|
no coincidía con lo que después salía al aire. */ ?>
|
||||||
<?php if ($m['tipo'] === 'video'): ?>
|
<?php if ($m['tipo'] === 'video'): ?>
|
||||||
<video muted style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>"></video>
|
<video muted style="width:64px;height:64px;object-fit:cover;border-radius:6px;background:#000" src="<?= htmlspecialchars($m['url']) ?>"></video>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<img style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>" alt="">
|
<img style="width:64px;height:64px;object-fit:cover;border-radius:6px;background:#000" src="<?= htmlspecialchars($m['url']) ?>" alt="">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="small fw-semibold"><i class="fas fa-<?= $m['tipo']==='video'?'film':'image' ?> me-1"></i><?= $m['tipo']==='video'?'Video':'Imagen' ?></div>
|
<div class="small fw-semibold"><i class="fas fa-<?= $m['tipo']==='video'?'film':'image' ?> me-1"></i><?= $m['tipo']==='video'?'Video':'Imagen' ?></div>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
|
|||||||
.kpi-card .kpi-val { font-size:2rem; font-weight:800; line-height:1.1; }
|
.kpi-card .kpi-val { font-size:2rem; font-weight:800; line-height:1.1; }
|
||||||
.kpi-card .kpi-lbl { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#64748b; margin-top:4px; }
|
.kpi-card .kpi-lbl { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#64748b; margin-top:4px; }
|
||||||
.kpi-card .kpi-sub { font-size:.75rem; color:#94a3b8; margin-top:2px; }
|
.kpi-card .kpi-sub { font-size:.75rem; color:#94a3b8; margin-top:2px; }
|
||||||
|
.kpi-ayuda { font-size:.62rem; color:#94a3b8; line-height:1.3; margin-top:5px;
|
||||||
|
border-top:1px solid #f1f5f9; padding-top:5px; }
|
||||||
|
|
||||||
/* ── Charts / tables section ── */
|
/* ── Charts / tables section ── */
|
||||||
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; }
|
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; }
|
||||||
@@ -287,6 +289,18 @@ try {
|
|||||||
<div class="lugar-grid" id="lugarGrid"></div>
|
<div class="lugar-grid" id="lugarGrid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Desglose por recepcionista -->
|
||||||
|
<div class="section-card" id="sectionRecepcionista" style="display:none">
|
||||||
|
<div class="section-title"><i class="fas fa-user-check me-1"></i>Por recepcionista</div>
|
||||||
|
<div class="lugar-grid" id="recepcionistaGrid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desglose por bacteriólogo -->
|
||||||
|
<div class="section-card" id="sectionBacteriologo" style="display:none">
|
||||||
|
<div class="section-title"><i class="fas fa-user-nurse me-1"></i>Por bacteriólogo</div>
|
||||||
|
<div class="lugar-grid" id="bacteriologoGrid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tabla detalle de turnos -->
|
<!-- Tabla detalle de turnos -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
@@ -445,7 +459,8 @@ function mostrarError(msg) {
|
|||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────
|
||||||
function renderDashboard(json) {
|
function renderDashboard(json) {
|
||||||
const { sesion, resumen, por_prioridad, por_lugar, consent_stats = {}, turnos } = json;
|
const { sesion, resumen, por_prioridad, por_lugar, consent_stats = {}, turnos,
|
||||||
|
por_recepcionista = [], por_bacteriologo = [] } = json;
|
||||||
|
|
||||||
// ── Sesión info ────────────────────────────────────────
|
// ── Sesión info ────────────────────────────────────────
|
||||||
const si = document.getElementById('sesionInfo');
|
const si = document.getElementById('sesionInfo');
|
||||||
@@ -471,14 +486,22 @@ function renderDashboard(json) {
|
|||||||
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
|
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
|
||||||
|
|
||||||
document.getElementById('kpiGrid').innerHTML = `
|
document.getElementById('kpiGrid').innerHTML = `
|
||||||
${kpiCard(total, '📋 Total turnos', '')}
|
${kpiCard(total, '📋 Turnos del día', '',
|
||||||
${kpiCard(atendidos,'✅ Atendidos', pct + '%')}
|
'Todos los turnos generados hoy, sin importar cómo terminaron.')}
|
||||||
${kpiCard(enEspera, '⏳ En espera', '')}
|
${kpiCard(atendidos, '✅ Atendidos', pct + ' % del día',
|
||||||
${kpiCard(ausentes, '🚫 Ausentes', '')}
|
'Ya terminaron su atención, más los que están siendo atendidos en este momento.')}
|
||||||
${kpiCard(cancelados,'❌ Cancelados', '')}
|
${kpiCard(enEspera, '⏳ Esperando', '',
|
||||||
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')}
|
'Todavía no los llaman, o ya salieron de recepción y esperan el puesto de toma.')}
|
||||||
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')}
|
${kpiCard(ausentes, '🚫 No se presentaron', '',
|
||||||
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total prom.', 'puerta a puerta')}
|
'Se les llamó y no llegaron. Recepción registra el motivo al marcarlos.')}
|
||||||
|
${kpiCard(cancelados, '❌ Cancelados', '',
|
||||||
|
'Turnos anulados antes de atenderlos.')}
|
||||||
|
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera para recepción', 'sacar el turno → lo llaman',
|
||||||
|
'Cuánto aguarda el paciente sentado desde que saca el turno hasta que recepción lo atiende.')}
|
||||||
|
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Tiempo en el puesto', 'toma de muestras',
|
||||||
|
'Lo que dura la toma. En protocolos prolongados (curvas) se cuenta hasta la primera toma: las horas de espera del examen no son trabajo del puesto.')}
|
||||||
|
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total en el laboratorio', 'llegada → salida',
|
||||||
|
'Todo el recorrido: espera + recepción + espera del puesto + toma. Por eso es mayor que la suma de las dos casillas anteriores.')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── Consentimientos ────────────────────────────────────
|
// ── Consentimientos ────────────────────────────────────
|
||||||
@@ -536,16 +559,51 @@ function renderDashboard(json) {
|
|||||||
secLug.style.display = 'none';
|
secLug.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tarjetas por persona ───────────────────────────────
|
||||||
|
renderPersonas('sectionRecepcionista', 'recepcionistaGrid', por_recepcionista, [
|
||||||
|
['Turnos', p => p.total],
|
||||||
|
['Atendidos', p => p.atendidos],
|
||||||
|
['Ausentes', p => p.ausentes],
|
||||||
|
['T. recepción prom.', p => p.tiempo_promedio_min !== null ? p.tiempo_promedio_min + ' min' : '—'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderPersonas('sectionBacteriologo', 'bacteriologoGrid', por_bacteriologo, [
|
||||||
|
['Turnos', p => p.total],
|
||||||
|
['Finalizados', p => p.finalizados],
|
||||||
|
['En servicio', p => p.en_servicio],
|
||||||
|
['T. servicio prom.', p => p.tiempo_promedio_min !== null ? p.tiempo_promedio_min + ' min' : '—'],
|
||||||
|
]);
|
||||||
|
|
||||||
// ── Tabla de turnos ────────────────────────────────────
|
// ── Tabla de turnos ────────────────────────────────────
|
||||||
_turnos = turnos;
|
_turnos = turnos;
|
||||||
renderTabla(turnos);
|
renderTabla(turnos);
|
||||||
}
|
}
|
||||||
|
|
||||||
function kpiCard(val, lbl, sub) {
|
// Tarjetas "quién atendió a cuántos". Reutiliza el grid de lugares para que
|
||||||
return `<div class="kpi-card">
|
// las tres secciones se vean iguales.
|
||||||
|
function renderPersonas(sectionId, gridId, filas, columnas) {
|
||||||
|
const sec = document.getElementById(sectionId);
|
||||||
|
if (!filas || !filas.length) { sec.style.display = 'none'; return; }
|
||||||
|
sec.style.display = '';
|
||||||
|
document.getElementById(gridId).innerHTML = filas.map(p => `
|
||||||
|
<div class="lugar-card">
|
||||||
|
<div class="lc-name"><i class="fas fa-user text-primary me-1"></i>${escHtml(p.nombre || '—')}</div>
|
||||||
|
${columnas.map(([lbl, val]) =>
|
||||||
|
`<div class="lc-row"><span>${lbl}</span><span>${escHtml(String(val(p) ?? '—'))}</span></div>`
|
||||||
|
).join('')}
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function kpiCard(val, lbl, sub, ayuda) {
|
||||||
|
// `ayuda` explica de dónde sale el número. Las etiquetas cortas se prestan
|
||||||
|
// para malentendidos —«servicio» no es lo mismo que «tiempo en el
|
||||||
|
// laboratorio»— y quien lee el tablero no tiene por qué adivinarlo.
|
||||||
|
const t = ayuda ? ` title="${escHtml(ayuda)}"` : '';
|
||||||
|
return `<div class="kpi-card"${t}>
|
||||||
<div class="kpi-val">${val !== null && val !== undefined ? escHtml(String(val)) : '—'}</div>
|
<div class="kpi-val">${val !== null && val !== undefined ? escHtml(String(val)) : '—'}</div>
|
||||||
<div class="kpi-lbl">${lbl}</div>
|
<div class="kpi-lbl">${lbl}</div>
|
||||||
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
|
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
|
||||||
|
${ayuda ? `<div class="kpi-ayuda">${escHtml(ayuda)}</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -271,9 +271,13 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Panel publicitario (se activa con ?video=URL) -->
|
<!-- Panel publicitario (se activa con ?video=URL) -->
|
||||||
<div id="video-panel" style="display:none;overflow:hidden;background:#000;position:relative">
|
<!-- El material es cuadrado: la caja también, y centrada. Estirada a
|
||||||
|
toda la altura de una columna angosta, cover recortaba los bordes
|
||||||
|
del video y solo se veía la franja central. -->
|
||||||
|
<div id="video-panel" style="display:none;overflow:hidden;background:#000;position:relative;
|
||||||
|
align-items:center;justify-content:center">
|
||||||
<video id="video-pub" autoplay muted loop playsinline
|
<video id="video-pub" autoplay muted loop playsinline
|
||||||
style="width:100%;height:100%;object-fit:cover">
|
style="width:100%;height:auto;aspect-ratio:1/1;max-height:100%;object-fit:cover">
|
||||||
<source id="video-src" src="">
|
<source id="video-src" src="">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,7 +342,7 @@ document.getElementById('lbl-area').textContent =
|
|||||||
const body = document.getElementById('display-body');
|
const body = document.getElementById('display-body');
|
||||||
src.src = videoUrl;
|
src.src = videoUrl;
|
||||||
vid.load();
|
vid.load();
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'flex'; // flex, para poder centrar el cuadro
|
||||||
// 3 columnas: turno | video | cola
|
// 3 columnas: turno | video | cola
|
||||||
body.style.gridTemplateColumns = '1fr 320px 280px';
|
body.style.gridTemplateColumns = '1fr 320px 280px';
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -112,8 +112,12 @@ $_hasVideo = (bool)$_tvPlaylist;
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
/* La columna del video se dimensiona sola: `auto` la deja del ancho que
|
||||||
|
pida su contenido, y el contenido es un cuadrado tan alto como el
|
||||||
|
cuerpo. Antes era una franja fija del 29% —proporción de reel—, así
|
||||||
|
que un video cuadrado quedaba diminuto y con medio espacio vacío. */
|
||||||
.pg-body.has-video {
|
.pg-body.has-video {
|
||||||
grid-template-columns: 1fr 29%;
|
grid-template-columns: 1fr auto;
|
||||||
}
|
}
|
||||||
.pg-body.has-video .turno-activo { display: none; }
|
.pg-body.has-video .turno-activo { display: none; }
|
||||||
|
|
||||||
@@ -240,16 +244,29 @@ $_hasVideo = (bool)$_tvPlaylist;
|
|||||||
}
|
}
|
||||||
.ul-item.activo .ul-dest { color: rgba(255,255,255,.85); }
|
.ul-item.activo .ul-dest { color: rgba(255,255,255,.85); }
|
||||||
|
|
||||||
/* ── Video reel ── */
|
/* ── Video cuadrado ──
|
||||||
|
El material ya no es de proporción reel sino cuadrado. La caja toma
|
||||||
|
todo el alto disponible y el ancho lo deduce de ahí (aspect-ratio),
|
||||||
|
así que el cuadrado sale tan grande como quepa. Se limita a la mitad
|
||||||
|
de la pantalla para no ahogar la columna de los llamados. */
|
||||||
.reel-wrap {
|
.reel-wrap {
|
||||||
display: none;
|
display: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
max-width: 50vw;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 8px 8px 8px 0;
|
padding: 8px 8px 8px 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.reel-wrap.has-video { display: block; }
|
.reel-wrap.has-video { display: flex; }
|
||||||
|
/* El cuadrado se define aquí y no en el contenedor: con box-sizing
|
||||||
|
border-box, el padding asimétrico del contenedor deformaría la
|
||||||
|
proporción. La columna se ajusta sola al ancho que resulte. */
|
||||||
.reel-wrap .reel-inner {
|
.reel-wrap .reel-inner {
|
||||||
width: 100%; height: 100%;
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
max-width: 100%;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: -8px 0 30px rgba(0,0,0,.08);
|
box-shadow: -8px 0 30px rgba(0,0,0,.08);
|
||||||
@@ -378,7 +395,7 @@ $_hasVideo = (bool)$_tvPlaylist;
|
|||||||
<div class="dot"></div>
|
<div class="dot"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="sonido-banner" onclick="activarSonido()" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem">
|
<div id="sonido-banner" onclick="activarSonido(true)" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem">
|
||||||
<i class="fas fa-volume-mute"></i> Haz clic aquí para activar el sonido
|
<i class="fas fa-volume-mute"></i> Haz clic aquí para activar el sonido
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -404,7 +421,7 @@ $_hasVideo = (bool)$_tvPlaylist;
|
|||||||
<div class="hd-right">
|
<div class="hd-right">
|
||||||
<div class="live-dot"></div>
|
<div class="live-dot"></div>
|
||||||
<div class="reloj" id="reloj">--:--:--</div>
|
<div class="reloj" id="reloj">--:--:--</div>
|
||||||
<button class="btn-fs" id="btn-sonido" onclick="activarSonido()" title="Activar sonido">
|
<button class="btn-fs" id="btn-sonido" onclick="activarSonido(true)" title="Activar sonido">
|
||||||
<i class="fas fa-volume-mute"></i>
|
<i class="fas fa-volume-mute"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
|
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
|
||||||
@@ -499,14 +516,22 @@ function tick() {
|
|||||||
tick(); setInterval(tick, 1000);
|
tick(); setInterval(tick, 1000);
|
||||||
|
|
||||||
/* ── Audio ── */
|
/* ── Audio ── */
|
||||||
let audioCtx = null, sonidoActivo = false;
|
// Arranca encendido. Esta pantalla vive en un televisor que nadie toca: si el
|
||||||
|
// sonido esperara un clic, cualquier recarga —corte de red, reinicio, refresco—
|
||||||
|
// la dejaba muda hasta que alguien fuera físicamente a tocarla, que es lo que
|
||||||
|
// venía pasando. Si el navegador termina bloqueándolo, se avisa con el letrero.
|
||||||
|
let audioCtx = null, sonidoActivo = true;
|
||||||
|
|
||||||
function activarSonido() {
|
function activarSonido(porGesto) {
|
||||||
try {
|
try {
|
||||||
if (!audioCtx) {
|
if (!audioCtx) {
|
||||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
} else if (audioCtx.state === 'suspended') {
|
// resume() es asíncrono: sin esto el letrero parpadeaba al abrir,
|
||||||
audioCtx.resume();
|
// porque el estado todavía decía "suspended" cuando ya iba a sonar.
|
||||||
|
audioCtx.onstatechange = revisarBloqueo;
|
||||||
|
}
|
||||||
|
if (audioCtx.state === 'suspended') {
|
||||||
|
audioCtx.resume().catch(() => {});
|
||||||
}
|
}
|
||||||
const g = audioCtx.createGain(); g.gain.setValueAtTime(0.001, audioCtx.currentTime);
|
const g = audioCtx.createGain(); g.gain.setValueAtTime(0.001, audioCtx.currentTime);
|
||||||
const o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
|
const o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
|
||||||
@@ -516,13 +541,39 @@ function activarSonido() {
|
|||||||
localStorage.setItem('turneroSonido', '1');
|
localStorage.setItem('turneroSonido', '1');
|
||||||
const btn = document.getElementById('btn-sonido');
|
const btn = document.getElementById('btn-sonido');
|
||||||
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
|
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
|
||||||
const banner = document.getElementById('sonido-banner');
|
// El pito de confirmación solo cuando alguien tocó: al arrancar sola, la
|
||||||
if (banner) banner.style.display = 'none';
|
// pantalla no tiene por qué pitar cada vez que se recarga.
|
||||||
setTimeout(playBeep, 100);
|
if (porGesto) setTimeout(playBeep, 100);
|
||||||
|
revisarBloqueo();
|
||||||
}
|
}
|
||||||
// Sin once:true para que cualquier clic reactive si la página recargó
|
|
||||||
document.addEventListener('click', () => { if (!sonidoActivo) activarSonido(); });
|
/**
|
||||||
document.addEventListener('touchstart', () => { if (!sonidoActivo) activarSonido(); });
|
* El navegador puede negarse a sonar sin un gesto humano. No hay forma de
|
||||||
|
* saberlo preguntando, solo mirando si el contexto quedó suspendido: si es así
|
||||||
|
* se muestra el letrero para que alguien toque la pantalla una vez.
|
||||||
|
*
|
||||||
|
* Se evita del todo abriendo Chrome en el televisor con
|
||||||
|
* --autoplay-policy=no-user-gesture-required
|
||||||
|
*/
|
||||||
|
function revisarBloqueo() {
|
||||||
|
const banner = document.getElementById('sonido-banner');
|
||||||
|
if (!banner) return;
|
||||||
|
const bloqueado = !audioCtx || audioCtx.state === 'suspended';
|
||||||
|
banner.style.display = bloqueado ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intento de arranque automático, en cuanto la página está lista
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => activarSonido(false));
|
||||||
|
} else {
|
||||||
|
activarSonido(false);
|
||||||
|
}
|
||||||
|
// Reintento: Chrome a veces deja el contexto suspendido un instante al abrir
|
||||||
|
setTimeout(() => { if (audioCtx && audioCtx.state === 'suspended') activarSonido(false); }, 2000);
|
||||||
|
|
||||||
|
// Cualquier gesto sirve de respaldo si el navegador bloqueó el arranque
|
||||||
|
document.addEventListener('click', () => activarSonido(true));
|
||||||
|
document.addEventListener('touchstart', () => activarSonido(true));
|
||||||
|
|
||||||
function playBeep() {
|
function playBeep() {
|
||||||
if (!sonidoActivo || !audioCtx) return;
|
if (!sonidoActivo || !audioCtx) return;
|
||||||
@@ -544,60 +595,247 @@ function playBeep() {
|
|||||||
|
|
||||||
// Selección de voz española — cargada una vez, reutilizada en cada anuncio
|
// Selección de voz española — cargada una vez, reutilizada en cada anuncio
|
||||||
let _vozES = null;
|
let _vozES = null;
|
||||||
|
// Voces preferidas, por nombre. Sabina (Windows) y Paulina (macOS) son las
|
||||||
|
// mexicanas locales: salen del propio equipo, así que nunca se cortan. Una
|
||||||
|
// colombiana local no existe —la es-CO de Chrome es de Google y se baja de
|
||||||
|
// internet en cada llamado—, y para leer letras, números y un nombre propio
|
||||||
|
// el acento mexicano se oye natural aquí; el de España no.
|
||||||
|
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
|
||||||
|
|
||||||
function getVozES() {
|
function getVozES() {
|
||||||
if (_vozES) return _vozES;
|
if (_vozES) return _vozES;
|
||||||
const voices = window.speechSynthesis.getVoices();
|
const voces = window.speechSynthesis.getVoices().filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
|
||||||
// Preferencia: es-CO → es-419 → es-MX → es-US → es-ES → cualquier es-*
|
if (!voces.length) return null;
|
||||||
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
|
|
||||||
const v = voices.find(v => v.lang === lang);
|
const locales = voces.filter(v => v.localService);
|
||||||
if (v) { _vozES = v; return v; }
|
const elegir = v => { _vozES = v; return v; };
|
||||||
|
|
||||||
|
// 1. Las pedidas por nombre, si están instaladas
|
||||||
|
for (const nombre of VOCES_PREFERIDAS) {
|
||||||
|
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
|
||||||
|
if (v) return elegir(v);
|
||||||
}
|
}
|
||||||
const v = voices.find(v => v.lang.startsWith('es'));
|
// 2. Cualquier otra local latinoamericana
|
||||||
if (v) { _vozES = v; }
|
for (const lang of ['es-MX', 'es-419', 'es-US', 'es-CO']) {
|
||||||
return _vozES;
|
const v = locales.find(v => v.lang === lang);
|
||||||
|
if (v) return elegir(v);
|
||||||
|
}
|
||||||
|
// 3. Cualquier local, aunque sea de España: peor acento, pero no se corta
|
||||||
|
if (locales.length) return elegir(locales[0]);
|
||||||
|
|
||||||
|
// 4. Sin ninguna local, queda la remota. Suena mejor, pero depende del wifi:
|
||||||
|
// es la que venía entrecortándose.
|
||||||
|
for (const lang of ['es-CO', 'es-419', 'es-MX', 'es-US', 'es-ES']) {
|
||||||
|
const v = voces.find(v => v.lang === lang);
|
||||||
|
if (v) return elegir(v);
|
||||||
|
}
|
||||||
|
return elegir(voces[0]);
|
||||||
}
|
}
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
|
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
|
||||||
// Keepalive: Chrome suspende speechSynthesis tras un rato sin uso.
|
// Keepalive con TRABAJO MUDO. El motor de voz (comprobado en macOS, y
|
||||||
// Solo debe correr con el sintetizador en reposo: pause() a mitad de una
|
// reportado también en Windows) se duerme tras un rato de ocio, y el primer
|
||||||
// frase la corta, y como el anuncio dura más de 10 segundos, antes lo
|
// speak() de ahí en adelante se ATASCA: speaking=true, cero audio, ni
|
||||||
// troceaba siempre.
|
// 'start' ni 'error' jamás. pause()/resume() no lo mantiene despierto —
|
||||||
|
// se verificó en vivo el 23/08: el primer llamado tras el ocio salía solo
|
||||||
|
// con el pito. Lo único que lo mantiene despierto es hablar de verdad:
|
||||||
|
// una letra a volumen cero cada 25 s, inaudible, con el motor en reposo.
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
if (!sonidoActivo) return;
|
||||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
|
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
|
||||||
window.speechSynthesis.pause();
|
const u = new SpeechSynthesisUtterance('a');
|
||||||
|
u.volume = 0; u.rate = 2;
|
||||||
window.speechSynthesis.resume();
|
window.speechSynthesis.resume();
|
||||||
}, 10000);
|
window.speechSynthesis.speak(u);
|
||||||
|
}, 25000);
|
||||||
}
|
}
|
||||||
// Auto-restore sonido si estaba activo antes de un reload
|
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
|
||||||
if (localStorage.getItem('turneroSonido') === '1') {
|
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
|
||||||
const banner = document.getElementById('sonido-banner');
|
|
||||||
if (banner) banner.style.display = 'flex';
|
// Espera antes de hablar: el pito dura 0,65 s y hablando encima tapaba
|
||||||
|
// "Turno X", que desde lejos sonaba como si el anuncio empezara ya empezado.
|
||||||
|
// A nivel de archivo porque showAnnouncement() también lo necesita.
|
||||||
|
const BEEP_MS = 700;
|
||||||
|
|
||||||
|
// ── Anclaje contra el recolector de basura ──
|
||||||
|
// Defecto conocido de Chrome: si nada referencia la SpeechSynthesisUtterance,
|
||||||
|
// el recolector puede llevársela A MITAD DE FRASE. El audio se corta en seco y
|
||||||
|
// 'end' no llega nunca. Como depende de cuándo pase el recolector, ocurre "a
|
||||||
|
// veces" y nunca se reproduce a voluntad. Cada locución queda anclada aquí
|
||||||
|
// hasta que termina.
|
||||||
|
const _uttAncladas = new Set();
|
||||||
|
|
||||||
|
// Token de generación: cancel() dispara 'error' (interrupted) sobre la
|
||||||
|
// locución vieja, y sin este guardián ese handler volvía a lanzar el
|
||||||
|
// reintento: DOS voces superpuestas diciendo lo mismo, a veces. Cada decir()
|
||||||
|
// toma un número; si al disparársele un evento ya no es el vigente, la
|
||||||
|
// locución fue superada (por un reintento o por el anuncio siguiente) y sus
|
||||||
|
// handlers no deben hacer nada más que soltar el anclaje.
|
||||||
|
let _vozGen = 0;
|
||||||
|
|
||||||
|
// Vigía de congelamiento: Chrome frena los relojes de una pestaña oculta a ~1
|
||||||
|
// tic por minuto. En ese estado la pantalla NO VE los llamados intermedios y
|
||||||
|
// no anuncia — y desde afuera parece un fallo de la voz. Si entre dos tics
|
||||||
|
// pasan más de 10 s, se deja constancia con el tamaño del hueco.
|
||||||
|
let _ultimoTic = Date.now();
|
||||||
|
setInterval(() => {
|
||||||
|
const ahora = Date.now();
|
||||||
|
const hueco = ahora - _ultimoTic;
|
||||||
|
_ultimoTic = ahora;
|
||||||
|
if (hueco > 10000) vozLog('lag', { gap_s: Math.round(hueco / 1000) });
|
||||||
|
}, 2000);
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) vozLog('lag', { oculta: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bitácora de la voz ──
|
||||||
|
// Los fallos intermitentes no se pueden depurar mirando la pantalla: hay que
|
||||||
|
// registrarlos cuando ocurren. Cada evento va a un anillo local (últimos 200,
|
||||||
|
// consultable con localStorage.tvVozLog en la consola del televisor) y al
|
||||||
|
// servidor con sendBeacon, que no bloquea ni exige respuesta.
|
||||||
|
function vozLog(evento, datos) {
|
||||||
|
const fila = Object.assign({ t: new Date().toISOString().slice(0, 19), e: evento }, datos || {});
|
||||||
|
try {
|
||||||
|
const ring = JSON.parse(localStorage.getItem('tvVozLog') || '[]');
|
||||||
|
ring.push(fila);
|
||||||
|
while (ring.length > 200) ring.shift();
|
||||||
|
localStorage.setItem('tvVozLog', JSON.stringify(ring));
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
navigator.sendBeacon(
|
||||||
|
'<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/log_tv.php',
|
||||||
|
new Blob([JSON.stringify(fila)], { type: 'application/json' })
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function anunciarTurno(codigo, destino, paciente) {
|
/**
|
||||||
|
* Dice el llamado en voz alta.
|
||||||
|
*
|
||||||
|
* Avisa por `onFin` cuando terminó de hablar, o cuando quedó claro que no va a
|
||||||
|
* hablar. Lo segundo importa tanto como lo primero: el cartel se cierra con esa
|
||||||
|
* señal, y si nunca llegaba se quedaba pegado en pantalla.
|
||||||
|
*
|
||||||
|
* Si la voz elegida no arranca —pasa cuando el equipo la lista pero no la puede
|
||||||
|
* usar— se reintenta una vez con la del navegador, en vez de quedarse mudo.
|
||||||
|
*
|
||||||
|
* @return bool si se intentó hablar. Falso significa pantalla sin sonido.
|
||||||
|
*/
|
||||||
|
function anunciarTurno(codigo, destino, paciente, onFin) {
|
||||||
playBeep();
|
playBeep();
|
||||||
if (!sonidoActivo || !('speechSynthesis' in window)) return null;
|
if (!sonidoActivo || !('speechSynthesis' in window)) return false;
|
||||||
|
|
||||||
const letras = codigo.split('').join(' ');
|
const letras = codigo.split('').join(' ');
|
||||||
const nomVoz = paciente ? paciente.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) : null;
|
// \w no cuenta las letras acentuadas, así que trataba la tilde como
|
||||||
|
// separador: "maría" salía "MaríA" y la voz lo pronunciaba raro.
|
||||||
|
const nomVoz = paciente
|
||||||
|
? paciente.toLowerCase().replace(/(^|\s)(\p{L})/gu, (_, sep, c) => sep + c.toUpperCase())
|
||||||
|
: null;
|
||||||
let texto = `Turno ${letras}`;
|
let texto = `Turno ${letras}`;
|
||||||
if (nomVoz) texto += `, ${nomVoz}`;
|
if (nomVoz) texto += `, ${nomVoz}`;
|
||||||
texto += `, pase a ${destino}`;
|
texto += `, pase a ${destino}`;
|
||||||
const utt = new SpeechSynthesisUtterance(texto);
|
|
||||||
utt.lang = 'es-CO';
|
|
||||||
const voz = getVozES();
|
|
||||||
if (voz) utt.voice = voz;
|
|
||||||
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
|
|
||||||
|
|
||||||
// Con el sintetizador libre se habla de inmediato. Solo cuando hay algo en
|
let avisado = false;
|
||||||
// curso hace falta cancelar y esperar un instante: Chrome ignora un speak()
|
const avisar = () => { if (!avisado) { avisado = true; onFin(); } };
|
||||||
// encadenado a un cancel() en el mismo ciclo.
|
|
||||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
|
const decir = (esReintento) => {
|
||||||
window.speechSynthesis.cancel();
|
const utt = new SpeechSynthesisUtterance(texto);
|
||||||
setTimeout(() => window.speechSynthesis.speak(utt), 80);
|
utt.lang = 'es-CO';
|
||||||
} else {
|
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
|
||||||
window.speechSynthesis.speak(utt);
|
|
||||||
}
|
_uttAncladas.add(utt);
|
||||||
return utt;
|
const soltar = () => _uttAncladas.delete(utt);
|
||||||
|
const pedido = Date.now();
|
||||||
|
const gen = ++_vozGen;
|
||||||
|
const vigente = () => gen === _vozGen;
|
||||||
|
|
||||||
|
let arranco = false;
|
||||||
|
utt.addEventListener('start', () => {
|
||||||
|
arranco = true;
|
||||||
|
if (!vigente()) return;
|
||||||
|
vozLog('start', { cod: codigo, ms: Date.now() - pedido,
|
||||||
|
voz: utt.voice ? utt.voice.name : '(navegador)',
|
||||||
|
local: utt.voice ? !!utt.voice.localService : null,
|
||||||
|
re: esReintento ? 1 : 0 });
|
||||||
|
});
|
||||||
|
utt.addEventListener('end', () => {
|
||||||
|
soltar();
|
||||||
|
if (!vigente()) return; // la canceló un reintento o el anuncio siguiente
|
||||||
|
vozLog('end', { cod: codigo, dur: Date.now() - pedido });
|
||||||
|
avisar();
|
||||||
|
});
|
||||||
|
utt.addEventListener('error', (ev) => {
|
||||||
|
soltar();
|
||||||
|
if (!vigente()) return; // 'interrupted' por un cancel nuestro: no reintentar
|
||||||
|
vozLog('error', { cod: codigo, err: ev.error || '?', re: esReintento ? 1 : 0 });
|
||||||
|
if (esReintento) { avisar(); return; }
|
||||||
|
// Invalidar ANTES de cancelar: el cancel dispara eventos sobre esta
|
||||||
|
// misma locución y sin esto el reintento salía por partida doble.
|
||||||
|
_vozGen++;
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
setTimeout(() => decir(true), 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Que speak() no lance nada no significa que vaya a sonar: si el
|
||||||
|
// navegador bloquea el audio, o la voz no sirve, no pasa absolutamente
|
||||||
|
// nada y 'error' tampoco llega. Solo se nota porque 'start' no ocurre.
|
||||||
|
// Rendirse con esta locución: romper el atasco y pasar al reintento
|
||||||
|
// (o, si ya era el reintento, declarar mudo el llamado y soltar el cartel).
|
||||||
|
const rendirse = () => {
|
||||||
|
soltar();
|
||||||
|
if (esReintento) {
|
||||||
|
vozLog('mudo', { cod: codigo });
|
||||||
|
avisar(); return;
|
||||||
|
}
|
||||||
|
// atasco=1: el motor JURA estar hablando (speaking) pero 'start'
|
||||||
|
// nunca llegó — el modo de fallo verificado en vivo el 23/08.
|
||||||
|
vozLog('sin_start', { cod: codigo,
|
||||||
|
atasco: window.speechSynthesis.speaking ? 1 : 0 });
|
||||||
|
_vozGen++; // los eventos del cancel no valen
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
setTimeout(() => decir(true), 500); // respiro: rehablar YA se atasca
|
||||||
|
};
|
||||||
|
const margen = (esReintento ? 0 : BEEP_MS) + 2500;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!vigente()) { soltar(); return; }
|
||||||
|
if (arranco) return;
|
||||||
|
// pending sin speaking = voz remota descargando: una prórroga de 6 s
|
||||||
|
// y solo entonces se da por perdida. Cortarla de una fue el defecto
|
||||||
|
// del entrecortado original.
|
||||||
|
if (window.speechSynthesis.pending && !window.speechSynthesis.speaking) {
|
||||||
|
setTimeout(() => { if (!vigente() || arranco) return; rendirse(); }, 6000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// speaking sin 'start' = ATASCO (finge hablar sin audio). Una locución
|
||||||
|
// que de verdad suena dispara 'start' de inmediato: romperlo.
|
||||||
|
rendirse();
|
||||||
|
}, margen);
|
||||||
|
|
||||||
|
// Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un
|
||||||
|
// speak() encadenado a un cancel() en el mismo ciclo.
|
||||||
|
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
// La voz se elige aquí y no al crear el anuncio: el navegador carga
|
||||||
|
// la lista de forma asíncrona, y en los primeros llamados tras abrir
|
||||||
|
// la página todavía venía vacía. Entonces no se asignaba voz y
|
||||||
|
// hablaba la del navegador —de ahí que a veces sonara un hombre y a
|
||||||
|
// veces una mujer—. A esta altura ya está cargada.
|
||||||
|
if (!esReintento) {
|
||||||
|
const voz = getVozES();
|
||||||
|
if (voz) utt.voice = voz;
|
||||||
|
}
|
||||||
|
// Tras un cancel(), en Windows y ChromeOS el motor puede quedar en
|
||||||
|
// pausa: speak() encola y jamás suena, sin error alguno. resume()
|
||||||
|
// sobre un motor sano no hace nada, así que se llama siempre.
|
||||||
|
window.speechSynthesis.resume();
|
||||||
|
window.speechSynthesis.speak(utt);
|
||||||
|
}, esReintento ? 250 : BEEP_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
decir(false);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Fullscreen ── */
|
/* ── Fullscreen ── */
|
||||||
@@ -642,8 +880,6 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
|
|||||||
|
|
||||||
ov.classList.add('visible');
|
ov.classList.add('visible');
|
||||||
|
|
||||||
const utt = anunciarTurno(codigo, destino, paciente);
|
|
||||||
|
|
||||||
bar.style.transition = 'none';
|
bar.style.transition = 'none';
|
||||||
bar.style.width = '100%';
|
bar.style.width = '100%';
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
@@ -668,11 +904,15 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
|
|||||||
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
|
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (utt) {
|
// anunciarTurno avisa tanto si terminó de hablar como si quedó claro que no
|
||||||
utt.addEventListener('end', () => setTimeout(cerrarCuandoToque, 400));
|
// va a hablar. Esperar solo el fin de la voz dejaba el cartel pegado en las
|
||||||
utt.addEventListener('error', cerrarCuandoToque);
|
// pantallas donde el navegador bloquea el audio.
|
||||||
// Red de seguridad: Chrome a veces no emite 'end' y el cartel quedaría fijo
|
const intentoHablar = anunciarTurno(codigo, destino, paciente,
|
||||||
setTimeout(dismiss, 25000);
|
() => setTimeout(cerrarCuandoToque, 400));
|
||||||
|
|
||||||
|
if (intentoHablar) {
|
||||||
|
// Red de seguridad: Chrome a veces no emite 'end' a mitad de una frase
|
||||||
|
setTimeout(dismiss, 20000);
|
||||||
} else {
|
} else {
|
||||||
setTimeout(dismiss, MIN_VISIBLE);
|
setTimeout(dismiss, MIN_VISIBLE);
|
||||||
}
|
}
|
||||||
@@ -727,18 +967,16 @@ function renderSnapshot(snap) {
|
|||||||
});
|
});
|
||||||
lastSeenKeys = currentKeys;
|
lastSeenKeys = currentKeys;
|
||||||
|
|
||||||
if (_firstLoad) {
|
// Al abrir la pantalla NO se anuncia nada por voz. Antes se llamaba al turno
|
||||||
_firstLoad = false;
|
// que estuviera arriba, pero eso no es un llamado nuevo: es el estado en que
|
||||||
const sorted = allTurnos.slice().sort((a, b) => new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0));
|
// se encontró el puesto. Si allí había un turno sin cerrar de horas atrás, el
|
||||||
if (sorted.length > 0) {
|
// televisor gritaba el nombre de un paciente que ya se había ido. Y como esta
|
||||||
const t = sorted[0];
|
// pantalla se recarga sola, cada recarga era una oportunidad de equivocarse.
|
||||||
const c = t.prioridad_color || '<?= $_labColor ?>';
|
//
|
||||||
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
|
// El recorrido de arriba ya dio por vistos los turnos presentes —registra la
|
||||||
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
|
// clave sin anunciar mientras _firstLoad siga en pie—, así que aquí solo hay
|
||||||
announcedCalls.add(key);
|
// que levantar la bandera: de la siguiente vuelta en adelante sí se anuncia.
|
||||||
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
|
if (_firstLoad) _firstLoad = false;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anuncioNuevo) renderLlamados();
|
if (anuncioNuevo) renderLlamados();
|
||||||
|
|
||||||
@@ -828,6 +1066,23 @@ cargarSnapshot();
|
|||||||
// 1 s en vez de 2: el llamado salía con hasta 2 s de retraso desde el clic
|
// 1 s en vez de 2: el llamado salía con hasta 2 s de retraso desde el clic
|
||||||
setInterval(cargarSnapshot, 1000);
|
setInterval(cargarSnapshot, 1000);
|
||||||
|
|
||||||
|
// ── Despertador SSE ──
|
||||||
|
// Esta pantalla corre en la ventana de un computador de uso mixto: puede
|
||||||
|
// quedar tapada o minimizada, y Chrome congela los relojes de las pestañas
|
||||||
|
// ocultas (~1 tic/min). El sondeo de arriba se paraliza y los llamados no
|
||||||
|
// suenan. Los eventos de red NO se congelan: el servidor emite cola_update en
|
||||||
|
// cada llamado y rellamado (sse_ping_at), y ese mensaje despierta la pantalla
|
||||||
|
// aunque esté de fondo. El sondeo queda como respaldo si el SSE se cae; si
|
||||||
|
// ambos fallan, el vigía de congelamiento lo deja anotado en la bitácora.
|
||||||
|
(function conectarSSE() {
|
||||||
|
if (!('EventSource' in window)) return;
|
||||||
|
try {
|
||||||
|
const es = new EventSource(BASE_API + 'sse_turno.php?area=recepcion');
|
||||||
|
es.addEventListener('cola_update', () => { try { cargarSnapshot(); } catch (_) {} });
|
||||||
|
// EventSource se reconecta solo tras un error; no hay nada que hacer aquí
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) cargarSnapshot(); });
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) cargarSnapshot(); });
|
||||||
window.addEventListener('focus', cargarSnapshot);
|
window.addEventListener('focus', cargarSnapshot);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* modules/turnero/views/firma.php
|
||||||
|
* Tablet de firma del paciente — /erp.php?m=turnero&v=firma
|
||||||
|
*
|
||||||
|
* Pantalla de cara al público: se pone frente al paciente en el mostrador y
|
||||||
|
* muestra, sola, el consentimiento de bienvenida cuando le toca firmarlo.
|
||||||
|
* Antes había que girarle el monitor a la recepcionista o pasarle el mouse.
|
||||||
|
*
|
||||||
|
* La tablet se identifica por la cookie del dispositivo, registrada desde
|
||||||
|
* Configuración. Sin ella no muestra nada: es lo que impide que cualquiera
|
||||||
|
* abra esta dirección y vea el nombre del paciente de turno.
|
||||||
|
*
|
||||||
|
* Conviene dejarla en modo kiosco, sin barra de direcciones, para que desde
|
||||||
|
* ella no se pueda navegar al resto del ERP.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$_fCfg = [];
|
||||||
|
try {
|
||||||
|
$__pdo = Database::getInstance()->getConnection();
|
||||||
|
$_fCfg = $__pdo->query(
|
||||||
|
"SELECT clave, valor FROM lab_config WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color')"
|
||||||
|
)->fetchAll(PDO::FETCH_KEY_PAIR) ?: [];
|
||||||
|
} catch (\Throwable $_) {}
|
||||||
|
|
||||||
|
$_fNombre = htmlspecialchars($_fCfg['empresa_nombre'] ?? 'Laboratorio');
|
||||||
|
$_fLogo = $_fCfg['doc_logo_base64'] ?? '';
|
||||||
|
$_fColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_fCfg['doc_color'] ?? '') ? $_fCfg['doc_color'] : '#1565c0';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
|
<title>Firma · <?= $_fNombre ?></title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root { --brand: <?= $_fColor ?>; }
|
||||||
|
html, body {
|
||||||
|
height: 100%; width: 100%; overflow: hidden;
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: #f8fafc; color: #1e293b;
|
||||||
|
-webkit-user-select: none; user-select: none;
|
||||||
|
}
|
||||||
|
.pantalla {
|
||||||
|
height: 100vh; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
text-align: center; padding: 4vh 5vw; gap: 2.5vh;
|
||||||
|
}
|
||||||
|
.pantalla.oculta { display: none; }
|
||||||
|
.logo { max-height: 14vh; max-width: 50vw; object-fit: contain; }
|
||||||
|
.marca { font-size: clamp(1.2rem, 3vw, 2rem); font-weight: 700; color: #64748b; }
|
||||||
|
|
||||||
|
.saludo { font-size: clamp(1.4rem, 4vw, 2.6rem); font-weight: 600; color: #94a3b8; }
|
||||||
|
.etiqueta { font-size: clamp(.9rem, 2vw, 1.2rem); font-weight: 700;
|
||||||
|
letter-spacing: 3px; text-transform: uppercase; color: #94a3b8; }
|
||||||
|
.paciente { font-size: clamp(1.8rem, 5.5vw, 3.4rem); font-weight: 800; line-height: 1.15; }
|
||||||
|
.codigo { font-size: clamp(1.1rem, 2.6vw, 1.6rem); font-weight: 700; color: var(--brand); }
|
||||||
|
|
||||||
|
/* Botón deliberadamente enorme: lo va a tocar gente mayor, de pie y de afán */
|
||||||
|
.btn-firmar {
|
||||||
|
margin-top: 2vh;
|
||||||
|
background: var(--brand); color: #fff; border: 0;
|
||||||
|
border-radius: 18px; cursor: pointer;
|
||||||
|
font-family: inherit; font-weight: 800;
|
||||||
|
font-size: clamp(1.5rem, 4.5vw, 2.6rem);
|
||||||
|
padding: clamp(1rem, 3.5vh, 2.2rem) clamp(2.5rem, 12vw, 6rem);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,.18);
|
||||||
|
transition: transform .12s ease, filter .12s ease;
|
||||||
|
}
|
||||||
|
.btn-firmar:active { transform: scale(.96); filter: brightness(.92); }
|
||||||
|
|
||||||
|
.ok-icono { font-size: clamp(3.5rem, 12vw, 7rem); color: #16a34a; line-height: 1; }
|
||||||
|
.ok-txt { font-size: clamp(1.4rem, 4vw, 2.4rem); font-weight: 700; color: #166534; }
|
||||||
|
|
||||||
|
.aviso { font-size: clamp(1rem, 2.4vw, 1.4rem); color: #94a3b8; max-width: 34ch; line-height: 1.5; }
|
||||||
|
|
||||||
|
/* El formulario ocupa toda la pantalla al abrirse */
|
||||||
|
#marco { position: fixed; inset: 0; z-index: 50; background: #fff; display: none; }
|
||||||
|
#marco.abierto { display: block; }
|
||||||
|
#marco iframe { width: 100%; height: 100%; border: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- En reposo: nadie a quien pedirle firma -->
|
||||||
|
<div class="pantalla" id="p-reposo">
|
||||||
|
<?php if ($_fLogo): ?><img class="logo" src="<?= htmlspecialchars($_fLogo) ?>" alt=""><?php endif; ?>
|
||||||
|
<div class="marca"><?= $_fNombre ?></div>
|
||||||
|
<div class="saludo">Bienvenido</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hay alguien y le falta firmar -->
|
||||||
|
<div class="pantalla oculta" id="p-firmar">
|
||||||
|
<div class="etiqueta">Turno <span id="f-codigo"></span></div>
|
||||||
|
<div class="paciente" id="f-paciente"></div>
|
||||||
|
<div class="aviso">Por favor lea y firme el consentimiento para continuar con su atención.</div>
|
||||||
|
<button class="btn-firmar" id="btn-firmar">Firmar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ya firmó -->
|
||||||
|
<div class="pantalla oculta" id="p-gracias">
|
||||||
|
<div class="ok-icono">✓</div>
|
||||||
|
<div class="ok-txt">¡Gracias!</div>
|
||||||
|
<div class="aviso">Su consentimiento quedó registrado. Puede continuar en el mostrador.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- La tablet no está registrada -->
|
||||||
|
<div class="pantalla oculta" id="p-sin-registro">
|
||||||
|
<div class="marca"><?= $_fNombre ?></div>
|
||||||
|
<div class="aviso">Esta tablet todavía no está asignada a un puesto.<br>
|
||||||
|
Regístrela desde Configuración del turnero.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="marco"><iframe id="marco-iframe" src="about:blank"></iframe></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/';
|
||||||
|
|
||||||
|
let turnoEnPantalla = null; // turno que se está mostrando
|
||||||
|
let firmando = false; // con el formulario abierto no se cambia de pantalla
|
||||||
|
|
||||||
|
function mostrar(id) {
|
||||||
|
['p-reposo','p-firmar','p-gracias','p-sin-registro']
|
||||||
|
.forEach(p => document.getElementById(p).classList.toggle('oculta', p !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirFormulario(url) {
|
||||||
|
firmando = true;
|
||||||
|
document.getElementById('marco-iframe').src = url;
|
||||||
|
document.getElementById('marco').classList.add('abierto');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cerrarFormulario() {
|
||||||
|
firmando = false;
|
||||||
|
document.getElementById('marco').classList.remove('abierto');
|
||||||
|
document.getElementById('marco-iframe').src = 'about:blank';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-firmar').addEventListener('click', () => {
|
||||||
|
const url = document.getElementById('btn-firmar').dataset.url;
|
||||||
|
if (url) abrirFormulario(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function revisar() {
|
||||||
|
// Con el formulario abierto no se toca la pantalla: el paciente está firmando
|
||||||
|
if (firmando) return;
|
||||||
|
|
||||||
|
let d;
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + 'get_firma_pendiente.php', { cache: 'no-store' });
|
||||||
|
d = await r.json();
|
||||||
|
} catch (_) {
|
||||||
|
return; // Sin red se deja lo que haya puesto; ya volverá
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d.ok) {
|
||||||
|
if (d.motivo === 'sin_dispositivo') mostrar('p-sin-registro');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.estado === 'por_firmar') {
|
||||||
|
document.getElementById('f-codigo').textContent = d.codigo || '';
|
||||||
|
document.getElementById('f-paciente').textContent = d.paciente || '';
|
||||||
|
document.getElementById('btn-firmar').dataset.url = d.url || '';
|
||||||
|
turnoEnPantalla = d.turno_id;
|
||||||
|
mostrar('p-firmar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.estado === 'firmado') {
|
||||||
|
// El "gracias" solo tiene sentido para quien acaba de firmar aquí.
|
||||||
|
// Si la tablet se abre con un turno ya firmado de antes, va a reposo.
|
||||||
|
mostrar(turnoEnPantalla === d.turno_id ? 'p-gracias' : 'p-reposo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// reposo: se olvida el turno anterior para no dejar datos de un paciente
|
||||||
|
// en pantalla mientras llega el siguiente
|
||||||
|
turnoEnPantalla = null;
|
||||||
|
mostrar('p-reposo');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ver_formulario_enviado.php avisa al terminar; si no llega el aviso, el sondeo
|
||||||
|
// se encarga igual cuando el consentimiento aparezca como firmado.
|
||||||
|
window.addEventListener('message', (e) => {
|
||||||
|
const t = e.data && e.data.type;
|
||||||
|
if (t === 'turneroFirmado') {
|
||||||
|
cerrarFormulario();
|
||||||
|
mostrar('p-gracias');
|
||||||
|
setTimeout(revisar, 2500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revisar();
|
||||||
|
setInterval(revisar, 2000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -110,6 +110,14 @@ Layout::open('Historial de Turnos', 'fas fa-history');
|
|||||||
|
|
||||||
/* ── Documentos firmados ── */
|
/* ── Documentos firmados ── */
|
||||||
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
|
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
|
||||||
|
.traza { border-left:2px solid #e2e8f0; margin:2px 0 0 6px; padding-left:10px; }
|
||||||
|
.traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.78rem; }
|
||||||
|
.traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
|
||||||
|
.traza-ico { width:14px; text-align:center; margin-top:2px; font-size:.72rem; }
|
||||||
|
.traza-txt { flex:1; color:#334155; }
|
||||||
|
.traza-quien { color:#64748b; font-style:italic; }
|
||||||
|
.traza-extra { color:#b45309; font-size:.74rem; margin-top:1px; }
|
||||||
|
.traza-nota { color:#94a3b8; font-size:.7rem; margin-top:6px; font-style:italic; }
|
||||||
.docs-lbl { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
|
.docs-lbl { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
|
||||||
.doc-pill { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:99px;
|
.doc-pill { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:99px;
|
||||||
font-size:.73rem; font-weight:600; margin:2px 2px; text-decoration:none; transition:opacity .15s; }
|
font-size:.73rem; font-weight:600; margin:2px 2px; text-decoration:none; transition:opacity .15s; }
|
||||||
@@ -472,11 +480,19 @@ function renderTabla(turnos) {
|
|||||||
const bacterio = esc(t.atendido_lugar_nombre || '—');
|
const bacterio = esc(t.atendido_lugar_nombre || '—');
|
||||||
|
|
||||||
// Exámenes: badges con código
|
// Exámenes: badges con código
|
||||||
const examsHtml = (t.examenes && t.examenes.length)
|
// Solo el conteo: con 9,5 exámenes de promedio por turno, las etiquetas de
|
||||||
? t.examenes.map(e => `<span class="badge bg-success-subtle text-success me-1 mb-1" style="font-size:.65rem">${esc(e.codigo)}</span>`).join('')
|
// código llenaban cinco renglones y la tabla quedaba ilegible. Los nombres
|
||||||
|
// completos ya están en el detalle desplegable, así que no se pierde nada.
|
||||||
|
const nExam = (t.examenes && t.examenes.length) ? t.examenes.length : 0;
|
||||||
|
const examsHtml = nExam
|
||||||
|
? `<span class="badge bg-success-subtle text-success" style="font-size:.68rem"
|
||||||
|
title="${esc(t.examenes.map(e => e.nombre || e.codigo).join(' · '))}">
|
||||||
|
${nExam} examen${nExam === 1 ? '' : 'es'}
|
||||||
|
</span>`
|
||||||
: '<span class="text-muted small">—</span>';
|
: '<span class="text-muted small">—</span>';
|
||||||
|
|
||||||
return `<tr data-id="${t.id}">
|
return `<tr data-id="${t.id}" style="cursor:pointer"
|
||||||
|
onclick="filaDetalle(event, '${rowId}', ${t.id})">
|
||||||
<td class="text-nowrap text-muted small">${esc(fecha)}</td>
|
<td class="text-nowrap text-muted small">${esc(fecha)}</td>
|
||||||
<td><strong style="color:${priCls}">${esc(t.codigo)}</strong></td>
|
<td><strong style="color:${priCls}">${esc(t.codigo)}</strong></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -496,19 +512,19 @@ function renderTabla(turnos) {
|
|||||||
<div class="fw-semibold">${lugar}</div>
|
<div class="fw-semibold">${lugar}</div>
|
||||||
<div class="text-muted" style="font-size:.72rem"><i class="fas fa-microscope me-1 opacity-50"></i>${bacterio}</div>
|
<div class="text-muted" style="font-size:.72rem"><i class="fas fa-microscope me-1 opacity-50"></i>${bacterio}</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="max-width:140px">${examsHtml}</td>
|
<td class="text-nowrap">${examsHtml}</td>
|
||||||
<td><span class="eb ${cls}">${lbl}</span></td>
|
<td><span class="eb ${cls}">${lbl}</span></td>
|
||||||
<td class="text-nowrap">${espMin}</td>
|
<td class="text-nowrap">${espMin}</td>
|
||||||
<td class="text-nowrap">${srvMin}</td>
|
<td class="text-nowrap">${srvMin}</td>
|
||||||
<td class="text-nowrap">${totMin}</td>
|
<td class="text-nowrap">${totMin}</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
<button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
|
<button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
|
||||||
onclick="toggleDetalle('${rowId}', this, ${t.id})"
|
onclick="event.stopPropagation();toggleDetalle('${rowId}', this, ${t.id})"
|
||||||
title="Ver detalles">
|
title="Ver detalles">
|
||||||
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
|
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-warning py-0 px-2"
|
<button class="btn btn-sm btn-outline-warning py-0 px-2"
|
||||||
onclick="abrirModalEstado(${t.id}, '${t.estado}', '${nombre}')"
|
onclick="event.stopPropagation();abrirModalEstado(${t.id}, '${t.estado}', '${nombre}')"
|
||||||
title="Cambiar estado">
|
title="Cambiar estado">
|
||||||
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
|
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -594,12 +610,32 @@ function renderDetalle(t) {
|
|||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
${relHtml}${examHtml}${comHtml}
|
${relHtml}${examHtml}${comHtml}
|
||||||
|
<div class="docs-section" id="traza-${t.id}">
|
||||||
|
<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>
|
||||||
|
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
|
||||||
|
</div>
|
||||||
<div class="docs-section" id="docs-${t.id}">
|
<div class="docs-section" id="docs-${t.id}">
|
||||||
<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div>
|
<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div>
|
||||||
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
|
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clic en cualquier parte de la fila: abre o cierra su detalle.
|
||||||
|
*
|
||||||
|
* Se ignora si el usuario estaba seleccionando texto —copiar un documento o un
|
||||||
|
* nombre es frecuente aquí y sería molesto que la fila se abriera al soltar— y
|
||||||
|
* si el clic cayó sobre un enlace o un botón, que tienen lo suyo que hacer.
|
||||||
|
*/
|
||||||
|
function filaDetalle(ev, rowId, turnoId) {
|
||||||
|
if (ev.target.closest('button, a, input, select')) return;
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.toString().length > 0) return;
|
||||||
|
const fila = document.querySelector(`tr[data-id="${turnoId}"]`);
|
||||||
|
const btn = fila ? fila.querySelector('button[title="Ver detalles"]') : null;
|
||||||
|
if (btn) toggleDetalle(rowId, btn, turnoId);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDetalle(rowId, btn, turnoId) {
|
function toggleDetalle(rowId, btn, turnoId) {
|
||||||
const row = document.getElementById(rowId);
|
const row = document.getElementById(rowId);
|
||||||
const icon = btn.querySelector('i');
|
const icon = btn.querySelector('i');
|
||||||
@@ -614,11 +650,79 @@ function toggleDetalle(rowId, btn, turnoId) {
|
|||||||
docsEl.dataset.loaded = '1';
|
docsEl.dataset.loaded = '1';
|
||||||
cargarDocumentosTurno(turnoId, docsEl);
|
cargarDocumentosTurno(turnoId, docsEl);
|
||||||
}
|
}
|
||||||
|
const trazaEl = document.getElementById(`traza-${turnoId}`);
|
||||||
|
if (trazaEl && !trazaEl.dataset.loaded) {
|
||||||
|
trazaEl.dataset.loaded = '1';
|
||||||
|
cargarTrazaTurno(turnoId, trazaEl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_URL_ROOT = '<?= BASE_URL ?>';
|
const BASE_URL_ROOT = '<?= BASE_URL ?>';
|
||||||
|
|
||||||
|
// ── Bitácora del turno ────────────────────────────────────────
|
||||||
|
// Junta en una sola línea de tiempo lo que antes estaba repartido: el recorrido
|
||||||
|
// del turno, los documentos, los comentarios del personal, las muestras y las
|
||||||
|
// acciones administrativas auditadas.
|
||||||
|
const TRAZA_ICONO = {
|
||||||
|
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
|
||||||
|
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
|
||||||
|
doc_ok:'fa-file-signature', comentario:'fa-comment', alerta:'fa-triangle-exclamation',
|
||||||
|
admin:'fa-user-shield',
|
||||||
|
};
|
||||||
|
const TRAZA_COLOR = {
|
||||||
|
alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb',
|
||||||
|
fin:'#0f766e', admin:'#7c3aed',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cargarTrazaTurno(turnoId, container) {
|
||||||
|
const titulo = '<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}get_traza_turno.php?turno_id=${turnoId}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.ok) { container.innerHTML = titulo + '<span class="docs-empty">No se pudo cargar</span>'; return; }
|
||||||
|
|
||||||
|
const ev = json.eventos || [];
|
||||||
|
if (!ev.length) { container.innerHTML = titulo + '<span class="docs-empty">Sin registros</span>'; return; }
|
||||||
|
|
||||||
|
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
|
||||||
|
const filas = ev.map(e => {
|
||||||
|
const color = TRAZA_COLOR[e.tipo] || '#94a3b8';
|
||||||
|
const ico = TRAZA_ICONO[e.tipo] || 'fa-circle';
|
||||||
|
const quien = e.quien ? `<span class="traza-quien">${esc(e.quien)}</span>` : '';
|
||||||
|
const extra = e.extra ? `<div class="traza-extra">${esc(e.extra)}</div>` : '';
|
||||||
|
return `<div class="traza-fila">
|
||||||
|
<span class="traza-hora">${hora(e.cuando)}</span>
|
||||||
|
<i class="fas ${ico} traza-ico" style="color:${color}"></i>
|
||||||
|
<div class="traza-txt"><span>${esc(e.texto)}</span> ${quien}${extra}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// El chat no se vuelca aquí: son datos personales del paciente y esta es
|
||||||
|
// una pantalla de consulta. Se indica que existe y se enlaza.
|
||||||
|
const chat = json.chat
|
||||||
|
? `<div class="traza-fila">
|
||||||
|
<span class="traza-hora"></span>
|
||||||
|
<i class="fab fa-whatsapp traza-ico" style="color:#25d366"></i>
|
||||||
|
<div class="traza-txt">
|
||||||
|
<a href="erp.php?m=turnero&v=chat&user_id=${json.chat.user_id}" target="_blank">
|
||||||
|
${json.chat.mensajes} mensaje(s) de WhatsApp ese día — ver conversación
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
// Se dice qué NO quedó registrado, para que la ausencia de un evento no
|
||||||
|
// se lea como prueba de que no ocurrió.
|
||||||
|
const nota = (json.sinRastro && json.sinRastro.length)
|
||||||
|
? `<div class="traza-nota">El sistema aún no deja rastro de: ${json.sinRastro.map(esc).join(' · ')}.</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
container.innerHTML = titulo + `<div class="traza">${filas}${chat}</div>` + nota;
|
||||||
|
} catch (_) {
|
||||||
|
container.innerHTML = titulo + '<span class="docs-empty">Error al cargar</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function cargarDocumentosTurno(turnoId, container) {
|
async function cargarDocumentosTurno(turnoId, container) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
|
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
|
||||||
|
|||||||
@@ -1002,6 +1002,14 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
|
|||||||
<label class="mpac-lbl">EPS</label>
|
<label class="mpac-lbl">EPS</label>
|
||||||
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-eps">
|
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-eps">
|
||||||
|
|
||||||
|
<label class="mpac-lbl">Sexo</label>
|
||||||
|
<select class="form-select form-select-sm mb-2" id="mpac-in-genero">
|
||||||
|
<option value="">— Sin registrar —</option>
|
||||||
|
<option value="M">Masculino</option>
|
||||||
|
<option value="F">Femenino</option>
|
||||||
|
<option value="O">Otro</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- Datos de identidad: requieren un paso deliberado -->
|
<!-- Datos de identidad: requieren un paso deliberado -->
|
||||||
<div id="mpac-identidad-lock" class="mpac-lock">
|
<div id="mpac-identidad-lock" class="mpac-lock">
|
||||||
<div class="mpac-lock-txt">
|
<div class="mpac-lock-txt">
|
||||||
@@ -1355,9 +1363,11 @@ async function seleccionarSinLlamar(turnoId) {
|
|||||||
mostrarCabeceraTurno(t);
|
mostrarCabeceraTurno(t);
|
||||||
await cargarFichaSolicitud(t.id);
|
await cargarFichaSolicitud(t.id);
|
||||||
clearInterval(pollingConsentId);
|
clearInterval(pollingConsentId);
|
||||||
if (!_esSoloEntrega(t))
|
// También para solo entrega de muestras: a esos turnos les corresponde el
|
||||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
// formulario del puesto, así que la lista tiene que refrescarse igual o al
|
||||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(t))
|
// firmar no se vería el cambio.
|
||||||
|
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||||
|
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
|
||||||
cargarFormEmbebido(t.id);
|
cargarFormEmbebido(t.id);
|
||||||
mostrarFichaMobile();
|
mostrarFichaMobile();
|
||||||
}
|
}
|
||||||
@@ -1400,9 +1410,8 @@ async function abrirFicha(turno) {
|
|||||||
mostrarCabeceraTurno(turno);
|
mostrarCabeceraTurno(turno);
|
||||||
await cargarFichaSolicitud(turno.id);
|
await cargarFichaSolicitud(turno.id);
|
||||||
clearInterval(pollingConsentId);
|
clearInterval(pollingConsentId);
|
||||||
if (!_esSoloEntrega(turno))
|
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
|
||||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(turno))
|
|
||||||
cargarFormEmbebido(turno.id);
|
cargarFormEmbebido(turno.id);
|
||||||
mostrarFichaMobile();
|
mostrarFichaMobile();
|
||||||
}
|
}
|
||||||
@@ -1468,10 +1477,15 @@ async function cargarFichaSolicitud(turnoId) {
|
|||||||
const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
|
const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
|
||||||
document.getElementById('badge-solo-muestras')?.classList.toggle('d-none', !esSoloMuestras);
|
document.getElementById('badge-solo-muestras')?.classList.toggle('d-none', !esSoloMuestras);
|
||||||
|
|
||||||
// Ocultar sección consentimientos y formulario embebido cuando es solo entrega
|
// La sección de formularios ya NO se esconde por ser solo entrega de
|
||||||
|
// muestras. Esos turnos no traen exámenes, pero sí les corresponde el
|
||||||
|
// formulario del puesto (F-LAB-08, Datos Toma de Muestras): el backend
|
||||||
|
// se lo venía creando y la interfaz lo ocultaba, así que se acumulaban
|
||||||
|
// creados y sin firmar. Quién se muestra lo decide renderConsentimientos
|
||||||
|
// según lo que realmente haya que llenar.
|
||||||
const secConsent = document.getElementById('sec-consent');
|
const secConsent = document.getElementById('sec-consent');
|
||||||
const secForm = document.getElementById('sec-form-embebido');
|
const secForm = document.getElementById('sec-form-embebido');
|
||||||
if (secConsent) secConsent.classList.toggle('d-none', esSoloMuestras);
|
if (secConsent) secConsent.classList.remove('d-none');
|
||||||
if (secForm) secForm.classList.add('d-none');
|
if (secForm) secForm.classList.add('d-none');
|
||||||
|
|
||||||
// Embarazada
|
// Embarazada
|
||||||
@@ -2230,7 +2244,7 @@ function resetFicha() {
|
|||||||
_muestrasActivas = [];
|
_muestrasActivas = [];
|
||||||
const secMuestras = document.getElementById('sec-muestras');
|
const secMuestras = document.getElementById('sec-muestras');
|
||||||
if (secMuestras) secMuestras.classList.add('d-none');
|
if (secMuestras) secMuestras.classList.add('d-none');
|
||||||
// Restaurar sección consent (puede haber sido ocultada por turno solo-muestras)
|
// Dejar la sección visible para la siguiente ficha
|
||||||
document.getElementById('sec-consent')?.classList.remove('d-none');
|
document.getElementById('sec-consent')?.classList.remove('d-none');
|
||||||
document.getElementById('badge-solo-muestras')?.classList.add('d-none');
|
document.getElementById('badge-solo-muestras')?.classList.add('d-none');
|
||||||
document.getElementById('ficha-orden').classList.add('d-none');
|
document.getElementById('ficha-orden').classList.add('d-none');
|
||||||
@@ -2515,6 +2529,7 @@ function mpacEditar(abrir) {
|
|||||||
document.getElementById('mpac-in-tel').value = p.telefono || p.celular || '';
|
document.getElementById('mpac-in-tel').value = p.telefono || p.celular || '';
|
||||||
document.getElementById('mpac-in-dir').value = p.direccion || '';
|
document.getElementById('mpac-in-dir').value = p.direccion || '';
|
||||||
document.getElementById('mpac-in-eps').value = p.eps || '';
|
document.getElementById('mpac-in-eps').value = p.eps || '';
|
||||||
|
document.getElementById('mpac-in-genero').value = p.genero || '';
|
||||||
document.getElementById('mpac-in-nombre').value = p.nombre_completo || '';
|
document.getElementById('mpac-in-nombre').value = p.nombre_completo || '';
|
||||||
document.getElementById('mpac-in-tipodoc').value = p.tipo_documento || 'CC';
|
document.getElementById('mpac-in-tipodoc').value = p.tipo_documento || 'CC';
|
||||||
document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || '';
|
document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || '';
|
||||||
@@ -2551,6 +2566,7 @@ async function mpacGuardarFicha() {
|
|||||||
telefono: val('mpac-in-tel'),
|
telefono: val('mpac-in-tel'),
|
||||||
direccion: val('mpac-in-dir'),
|
direccion: val('mpac-in-dir'),
|
||||||
eps: val('mpac-in-eps'),
|
eps: val('mpac-in-eps'),
|
||||||
|
genero: val('mpac-in-genero'),
|
||||||
};
|
};
|
||||||
// Los datos de identidad solo se envían si se desbloquearon
|
// Los datos de identidad solo se envían si se desbloquearon
|
||||||
if (!document.getElementById('mpac-identidad').classList.contains('d-none')) {
|
if (!document.getElementById('mpac-identidad').classList.contains('d-none')) {
|
||||||
|
|||||||
@@ -1553,7 +1553,7 @@ function abrirFicha(turno) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} else if (turno.paciente_nombre && /^\d{5,15}$/.test(turno.paciente_nombre.trim())) {
|
} else if (turno.paciente_nombre && /^\d{5,15}$/.test(turno.paciente_nombre.trim())) {
|
||||||
// El kiosko capturó una cédula como nombre — consultar RIPS de inmediato
|
// El kiosko capturó una cédula como nombre — consultar RIPS de inmediato
|
||||||
consultarExamenesRips(turno.paciente_nombre.trim());
|
iniciarSondeoRips(turno.paciente_nombre.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargar consentimientos del desk actual para este turno
|
// Cargar consentimientos del desk actual para este turno
|
||||||
@@ -1673,7 +1673,7 @@ function seleccionarPaciente(pac) {
|
|||||||
|
|
||||||
// Consultar exámenes recientes en RIPS (últimos 5 min)
|
// Consultar exámenes recientes en RIPS (últimos 5 min)
|
||||||
const cedula = (pac.numero_documento || pac.documento || '').toString().trim();
|
const cedula = (pac.numero_documento || pac.documento || '').toString().trim();
|
||||||
if (cedula) consultarExamenesRips(cedula);
|
if (cedula) iniciarSondeoRips(cedula);
|
||||||
filtrarExamenesPorGenero();
|
filtrarExamenesPorGenero();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1696,6 +1696,31 @@ function filtrarExamenesPorGenero() {
|
|||||||
// ── Exámenes desde RIPS ───────────────────────────────────────
|
// ── Exámenes desde RIPS ───────────────────────────────────────
|
||||||
let _ripsData = null;
|
let _ripsData = null;
|
||||||
|
|
||||||
|
// ── Sondeo RIPS ──
|
||||||
|
// La facturación en el sistema del laboratorio ocurre MIENTRAS la
|
||||||
|
// recepcionista atiende, así que consultar una sola vez al vincular al
|
||||||
|
// paciente casi siempre llega temprano: en 7 días, 547 de 549 registros RIPS
|
||||||
|
// aparecieron DESPUÉS de esa única consulta, y nadie volvía a preguntar. Eso
|
||||||
|
// es lo que se percibía como "RIPS se demora". Se sondea cada 10 s hasta 10
|
||||||
|
// minutos, y se detiene al encontrar, al limpiar la ficha o al crear el turno.
|
||||||
|
let _ripsSondeoId = null;
|
||||||
|
let _ripsSondeoQuedan = 0;
|
||||||
|
|
||||||
|
function iniciarSondeoRips(cedula) {
|
||||||
|
detenerSondeoRips();
|
||||||
|
if (!cedula) return;
|
||||||
|
_ripsSondeoQuedan = 60; // 60 intentos × 10 s = 10 min
|
||||||
|
consultarExamenesRips(cedula); // el primero, de inmediato
|
||||||
|
_ripsSondeoId = setInterval(() => {
|
||||||
|
if (--_ripsSondeoQuedan <= 0 || _ripsData) { detenerSondeoRips(); return; }
|
||||||
|
consultarExamenesRips(cedula);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detenerSondeoRips() {
|
||||||
|
if (_ripsSondeoId) { clearInterval(_ripsSondeoId); _ripsSondeoId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
async function consultarExamenesRips(cedula) {
|
async function consultarExamenesRips(cedula) {
|
||||||
_ripsData = null;
|
_ripsData = null;
|
||||||
document.getElementById('banner-rips').classList.add('d-none');
|
document.getElementById('banner-rips').classList.add('d-none');
|
||||||
@@ -1704,6 +1729,15 @@ async function consultarExamenesRips(cedula) {
|
|||||||
const r = await fetch(`${BASE_WA}api/lab/get_examenes_rips.php?cedula=${encodeURIComponent(cedula)}`);
|
const r = await fetch(`${BASE_WA}api/lab/get_examenes_rips.php?cedula=${encodeURIComponent(cedula)}`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!d.ok || !d.encontrados?.length) return;
|
if (!d.ok || !d.encontrados?.length) return;
|
||||||
|
|
||||||
|
// La respuesta pudo llegar DESPUÉS de que la recepcionista cambió de
|
||||||
|
// ficha: sin esta comprobación, los exámenes de un paciente se
|
||||||
|
// cargarían en la ficha del siguiente. Si la cédula ya no corresponde
|
||||||
|
// a quien está en pantalla, la respuesta se descarta.
|
||||||
|
const cedulaEnPantalla = (pacienteActivo?.numero_documento
|
||||||
|
|| pacienteActivo?.documento
|
||||||
|
|| turnoActivo?.paciente_nombre || '').toString().trim();
|
||||||
|
if (cedulaEnPantalla !== cedula) { detenerSondeoRips(); return; }
|
||||||
_ripsData = d;
|
_ripsData = d;
|
||||||
const hora = d.hora ? ' (' + String(d.hora).slice(0, 5) + ')' : '';
|
const hora = d.hora ? ' (' + String(d.hora).slice(0, 5) + ')' : '';
|
||||||
const warn = document.getElementById('banner-rips-warn');
|
const warn = document.getElementById('banner-rips-warn');
|
||||||
@@ -1715,7 +1749,9 @@ async function consultarExamenesRips(cedula) {
|
|||||||
}
|
}
|
||||||
// Si los exámenes vienen del cache local (enviados por RIPS scheduler) se cargan
|
// Si los exámenes vienen del cache local (enviados por RIPS scheduler) se cargan
|
||||||
// automáticamente. Si vienen del pull en vivo se muestra el banner para confirmación.
|
// automáticamente. Si vienen del pull en vivo se muestra el banner para confirmación.
|
||||||
if (d.fuente === 'cache') {
|
detenerSondeoRips();
|
||||||
|
const yaHayManuales = examTS && examTS.items && examTS.items.length > 0;
|
||||||
|
if (d.fuente === 'cache' && !yaHayManuales) {
|
||||||
await cargarExamenesRips();
|
await cargarExamenesRips();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('banner-rips-txt').textContent =
|
document.getElementById('banner-rips-txt').textContent =
|
||||||
@@ -1760,6 +1796,7 @@ async function cargarExamenesRips() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function descartarRips() {
|
function descartarRips() {
|
||||||
|
detenerSondeoRips();
|
||||||
_ripsData = null;
|
_ripsData = null;
|
||||||
document.getElementById('banner-rips').classList.add('d-none');
|
document.getElementById('banner-rips').classList.add('d-none');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* modules/turnero/views/voces.php
|
||||||
|
* Prueba de voces del televisor — /erp.php?m=turnero&v=voces
|
||||||
|
*
|
||||||
|
* Las voces disponibles no las decide el ERP sino el equipo: dependen del
|
||||||
|
* sistema operativo y del navegador del televisor. Por eso esta página hay que
|
||||||
|
* abrirla EN EL TELEVISOR, no en el computador de la oficina: cada equipo
|
||||||
|
* ofrece una lista distinta.
|
||||||
|
*
|
||||||
|
* Sirve para oír cada voz con el texto real de un llamado y elegir la que
|
||||||
|
* quede. Distingue las locales de las que se bajan de internet, que son las
|
||||||
|
* que se entrecortan cuando el wifi flaquea.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Prueba de voces · Turnero</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 24px;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #0f172a; color: #e2e8f0;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5rem; margin: 0 0 4px; }
|
||||||
|
.sub { color: #94a3b8; font-size: .95rem; margin-bottom: 20px; }
|
||||||
|
.aviso {
|
||||||
|
background: #1e293b; border-left: 4px solid #38bdf8;
|
||||||
|
padding: 12px 16px; border-radius: 6px; margin-bottom: 22px;
|
||||||
|
font-size: .92rem; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.fila {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
background: #1e293b; border-radius: 8px;
|
||||||
|
padding: 14px 16px; margin-bottom: 10px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
.fila.elegida { border-color: #38bdf8; background: #1e3a52; }
|
||||||
|
.nom { font-weight: 600; flex: 1; min-width: 0; }
|
||||||
|
.nom small { display: block; font-weight: 400; color: #94a3b8; font-size: .8rem; }
|
||||||
|
.tag {
|
||||||
|
font-size: .72rem; padding: 3px 9px; border-radius: 99px;
|
||||||
|
white-space: nowrap; font-weight: 600;
|
||||||
|
}
|
||||||
|
.local { background: #14532d; color: #86efac; }
|
||||||
|
.remota { background: #7c2d12; color: #fdba74; }
|
||||||
|
.actual { background: #0c4a6e; color: #7dd3fc; }
|
||||||
|
button {
|
||||||
|
background: #0284c7; color: #fff; border: 0;
|
||||||
|
padding: 10px 20px; border-radius: 6px; cursor: pointer;
|
||||||
|
font-size: .95rem; font-weight: 600; white-space: nowrap;
|
||||||
|
}
|
||||||
|
button:hover { background: #0369a1; }
|
||||||
|
#vacio { color: #94a3b8; padding: 20px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Prueba de voces</h1>
|
||||||
|
<div class="sub">Ábrala <strong>en el televisor</strong>: cada equipo ofrece voces distintas.</div>
|
||||||
|
|
||||||
|
<div class="aviso">
|
||||||
|
Las <span class="tag local">local</span> salen del propio equipo: nunca se cortan, aunque suenen
|
||||||
|
algo más robóticas.<br>
|
||||||
|
Las <span class="tag remota">de internet</span> suenan más naturales, pero se bajan de los
|
||||||
|
servidores de Google en cada llamado. Con wifi débil se entrecortan o no suenan.
|
||||||
|
<br><br>
|
||||||
|
Oiga las que aparezcan y dígame cuál prefiere.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="lista"><div id="vacio">Cargando voces…</div></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// El mismo texto que dice la pantalla de turnos, para juzgarlas en condiciones
|
||||||
|
const TEXTO = 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2';
|
||||||
|
|
||||||
|
// Réplica de la preferencia de display_global.php, para señalar cuál está
|
||||||
|
// sonando. Si allá se cambia el orden, hay que cambiarlo aquí también.
|
||||||
|
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
|
||||||
|
|
||||||
|
function vozActual(voces) {
|
||||||
|
if (!voces.length) return null;
|
||||||
|
const locales = voces.filter(v => v.localService);
|
||||||
|
for (const nombre of VOCES_PREFERIDAS) {
|
||||||
|
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
for (const lang of ['es-MX','es-419','es-US','es-CO']) {
|
||||||
|
const v = locales.find(v => v.lang === lang);
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
if (locales.length) return locales[0];
|
||||||
|
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
|
||||||
|
const v = voces.find(v => v.lang === lang);
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
return voces[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function probar(voz) {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
const u = new SpeechSynthesisUtterance(TEXTO);
|
||||||
|
u.voice = voz;
|
||||||
|
u.lang = voz.lang;
|
||||||
|
u.rate = 0.95;
|
||||||
|
u.pitch = 1.05;
|
||||||
|
window.speechSynthesis.speak(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pintar() {
|
||||||
|
const todas = window.speechSynthesis.getVoices();
|
||||||
|
const es = todas.filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
|
||||||
|
const lista = document.getElementById('lista');
|
||||||
|
const actual = vozActual(es);
|
||||||
|
|
||||||
|
if (!es.length) {
|
||||||
|
lista.innerHTML = '<div id="vacio">Este equipo no tiene ninguna voz en español instalada. '
|
||||||
|
+ 'Habría que instalarle una desde la configuración del sistema, '
|
||||||
|
+ 'o dejar el llamado solo con el pito y el cartel.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Las locales primero: son las que interesan
|
||||||
|
es.sort((a, b) => (b.localService - a.localService) || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
lista.innerHTML = '';
|
||||||
|
es.forEach((v, i) => {
|
||||||
|
const fila = document.createElement('div');
|
||||||
|
fila.className = 'fila' + (v === actual ? ' elegida' : '');
|
||||||
|
fila.innerHTML =
|
||||||
|
'<div class="nom">' + v.name + '<small>' + v.lang + '</small></div>'
|
||||||
|
+ (v.localService ? '<span class="tag local">local</span>'
|
||||||
|
: '<span class="tag remota">de internet</span>')
|
||||||
|
+ (v === actual ? '<span class="tag actual">la de ahora</span>' : '');
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.textContent = 'Oír';
|
||||||
|
b.onclick = () => probar(v);
|
||||||
|
fila.appendChild(b);
|
||||||
|
lista.appendChild(fila);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVoices() suele venir vacío en la primera llamada: el navegador las carga aparte
|
||||||
|
window.speechSynthesis.onvoiceschanged = pintar;
|
||||||
|
pintar();
|
||||||
|
setTimeout(pintar, 600);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* scripts/backfill_bsuid.php
|
||||||
|
*
|
||||||
|
* Rellena users.bsuid a partir del histórico de webhook_logs.
|
||||||
|
*
|
||||||
|
* Meta manda el BSUID en todos los webhooks de mensaje, así que en el histórico
|
||||||
|
* están las equivalencias BSUID↔teléfono de la gente que escribió cuando aún
|
||||||
|
* mostraba su número. Cargarlas significa que, el día que oculten el teléfono,
|
||||||
|
* el bot siga sabiendo quiénes son en vez de tratarlos como desconocidos.
|
||||||
|
*
|
||||||
|
* Es idempotente: se puede correr las veces que haga falta.
|
||||||
|
*
|
||||||
|
* La lectura del histórico tarda más de media hora (son ~230.000 registros de
|
||||||
|
* un servidor remoto), y mantener la conexión abierta todo ese rato la mataba
|
||||||
|
* antes de empezar a escribir: "MySQL server has gone away". Por eso el mapa se
|
||||||
|
* guarda en disco y la escritura usa una conexión nueva. De paso, reintentar
|
||||||
|
* sale gratis: si el mapa ya está en caché, no se vuelve a leer la tabla.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* php scripts/backfill_bsuid.php --simular (no escribe, solo informa)
|
||||||
|
* php scripts/backfill_bsuid.php
|
||||||
|
* php scripts/backfill_bsuid.php --releer (ignora la caché y relee el histórico)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
$simular = in_array('--simular', $argv, true);
|
||||||
|
$releer = in_array('--releer', $argv, true);
|
||||||
|
echo $simular ? "Modo simulación: no se escribe nada.\n\n" : "Aplicando cambios.\n\n";
|
||||||
|
|
||||||
|
$cache = sys_get_temp_dir() . '/backfill_bsuid_mapa.json';
|
||||||
|
|
||||||
|
// --- 1. Recorrer el histórico y armar el mapa BSUID → teléfono ---
|
||||||
|
$mapa = [];
|
||||||
|
$sinTelefono = [];
|
||||||
|
|
||||||
|
if (!$releer && is_readable($cache)) {
|
||||||
|
$guardado = json_decode(file_get_contents($cache), true) ?: [];
|
||||||
|
$mapa = $guardado['mapa'] ?? [];
|
||||||
|
$sinTelefono = $guardado['sinTelefono'] ?? [];
|
||||||
|
printf("Mapa tomado de la caché (%s, del %s).\n", basename($cache), date('Y-m-d H:i', filemtime($cache)));
|
||||||
|
printf("Para releer el histórico: --releer\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$mapa) {
|
||||||
|
echo "Leyendo webhook_logs (tarda; es una sola vez)...\n";
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Una sola pasada sin buffer: son más de 400.000 registros, así que ni caben
|
||||||
|
// en memoria de golpe ni conviene reconsultar la tabla por bloques (cada bloque
|
||||||
|
// volvía a recorrerla entera y tardaba una eternidad).
|
||||||
|
$conn = $db->getConnection();
|
||||||
|
$bufferPrevio = $conn->getAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY);
|
||||||
|
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
|
||||||
|
|
||||||
|
$leidos = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $conn->query(
|
||||||
|
"SELECT request_body FROM webhook_logs
|
||||||
|
WHERE request_body LIKE '%user_id%' AND request_body LIKE '%\"messages\"%'"
|
||||||
|
);
|
||||||
|
|
||||||
|
while ($fila = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$leidos++;
|
||||||
|
if ($leidos % 50000 === 0) printf(" ...%d registros\n", $leidos);
|
||||||
|
$d = json_decode($fila['request_body'], true);
|
||||||
|
if (!$d) continue;
|
||||||
|
|
||||||
|
foreach ($d['entry'] ?? [] as $entry) {
|
||||||
|
foreach ($entry['changes'] ?? [] as $cambio) {
|
||||||
|
$v = $cambio['value'] ?? [];
|
||||||
|
|
||||||
|
// El teléfono puede venir en contacts[].wa_id o en messages[].from
|
||||||
|
foreach ($v['contacts'] ?? [] as $c) {
|
||||||
|
if (!empty($c['user_id']) && !empty($c['wa_id'])) {
|
||||||
|
$mapa[$c['user_id']] = $c['wa_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($v['messages'] ?? [] as $m) {
|
||||||
|
$uid = $m['from_user_id'] ?? null;
|
||||||
|
if (!$uid) continue;
|
||||||
|
if (!empty($m['from'])) $mapa[$uid] = $m['from'];
|
||||||
|
else $sinTelefono[$uid] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cuando alguien comparte su contacto, el teléfono llega aquí
|
||||||
|
foreach ($v['messages'] ?? [] as $m) {
|
||||||
|
if (($m['type'] ?? '') !== 'contacts') continue;
|
||||||
|
$uid = $m['from_user_id'] ?? null;
|
||||||
|
foreach ($m['contacts'] ?? [] as $c) {
|
||||||
|
foreach ($c['phones'] ?? [] as $t) {
|
||||||
|
$num = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||||
|
if ($uid && $num) $mapa[$uid] = preg_replace('/[^0-9]/', '', $num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$stmt->closeCursor();
|
||||||
|
} finally {
|
||||||
|
// Dejar la conexión como estaba
|
||||||
|
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $bufferPrevio);
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("\n registros revisados: %d\n", $leidos);
|
||||||
|
printf(" equivalencias encontradas: %d\n", count($mapa));
|
||||||
|
file_put_contents($cache, json_encode(['mapa' => $mapa, 'sinTelefono' => $sinTelefono]));
|
||||||
|
printf(" mapa guardado en %s\n", $cache);
|
||||||
|
|
||||||
|
// Se termina aquí a propósito. Tras media hora de lectura el servidor ya cerró
|
||||||
|
// esta conexión, y el singleton de Database no sabe reconectar: escribir ahora
|
||||||
|
// falla con "MySQL server has gone away" a mitad del recorrido, que es justo lo
|
||||||
|
// que pasó la primera vez. La segunda ejecución toma el mapa de la caché en un
|
||||||
|
// segundo y escribe con la conexión sana.
|
||||||
|
echo "\nMapa listo. Ejecute otra vez el comando para aplicarlo.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(" equivalencias encontradas: %d\n", count($mapa));
|
||||||
|
printf(" personas que llegaron sin teléfono: %d\n", count($sinTelefono));
|
||||||
|
|
||||||
|
// --- 1b. Un solo BSUID por teléfono ---
|
||||||
|
// Hay números que aparecen con dos identificadores: la línea cambió de dueño
|
||||||
|
// (reciclada) o la persona se registró de nuevo. Si se recorre el mapa tal cual,
|
||||||
|
// ambos escriben sobre el mismo usuario y el que queda depende del orden: cada
|
||||||
|
// ejecución dejaba un valor distinto. Se resuelve antes de tocar la base,
|
||||||
|
// quedándose con el visto más tarde, y se escribe una sola vez por teléfono.
|
||||||
|
$porTelefono = [];
|
||||||
|
$elegido = [];
|
||||||
|
foreach ($mapa as $b => $t) {
|
||||||
|
$porTelefono[$t][] = $b;
|
||||||
|
$elegido[$t] = $b; // el orden del mapa es el de lectura: el último gana
|
||||||
|
}
|
||||||
|
$ambiguos = array_filter($porTelefono, fn($bs) => count($bs) > 1);
|
||||||
|
printf(" teléfonos distintos: %d\n", count($porTelefono));
|
||||||
|
printf(" teléfonos con más de un BSUID: %d (se toma el más reciente)\n\n", count($ambiguos));
|
||||||
|
foreach (array_slice($ambiguos, 0, 10, true) as $t => $bs) {
|
||||||
|
printf(" %s → %s\n", substr($t, 0, -4) . '****', implode(', ', $bs));
|
||||||
|
}
|
||||||
|
if (count($ambiguos) > 10) printf(" (y %d más)\n", count($ambiguos) - 10);
|
||||||
|
if ($ambiguos) echo "\n";
|
||||||
|
|
||||||
|
// --- 2. Escribir el BSUID en el usuario que corresponde ---
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$marcados = 0; $noExisten = 0; $yaEstaban = 0; $conflictos = 0;
|
||||||
|
|
||||||
|
foreach ($elegido as $telefono => $bsuid) {
|
||||||
|
$u = $db->fetch("SELECT id, bsuid FROM users WHERE phone_number = :t", ['t' => $telefono]);
|
||||||
|
if (!$u) { $noExisten++; continue; }
|
||||||
|
if ($u['bsuid'] === $bsuid) { $yaEstaban++; continue; }
|
||||||
|
if (!empty($u['bsuid'])) {
|
||||||
|
printf(" aviso: %s tenía %s y queda con %s\n", $telefono, $u['bsuid'], $bsuid);
|
||||||
|
$conflictos++;
|
||||||
|
}
|
||||||
|
if (!$simular) {
|
||||||
|
try {
|
||||||
|
$db->update('users', ['bsuid' => $bsuid], 'id = ?', [$u['id']]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
printf(" error en %s: %s\n", $telefono, $e->getMessage());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$marcados++;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("\n usuarios marcados con su BSUID: %d\n", $marcados);
|
||||||
|
printf(" ya lo tenían: %d\n", $yaEstaban);
|
||||||
|
printf(" sin usuario en la base: %d\n", $noExisten);
|
||||||
|
printf(" BSUID reemplazados (cambio de número): %d\n", $conflictos);
|
||||||
|
|
||||||
|
// --- 3. ¿A cuántos de los perdidos rescatamos? ---
|
||||||
|
$rescatables = array_intersect_key($mapa, $sinTelefono);
|
||||||
|
printf("\n de los que llegaron sin teléfono, quedan identificados: %d\n", count($rescatables));
|
||||||
|
foreach ($rescatables as $b => $t) {
|
||||||
|
printf(" %-24s → %s\n", $b, substr($t, 0, -4) . '****');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendientes = array_diff_key($sinTelefono, $mapa);
|
||||||
|
printf("\n siguen sin identificar: %d (hay que pedirles el contacto)\n", count($pendientes));
|
||||||
|
|
||||||
|
echo $simular ? "\nSimulación terminada, no se escribió nada.\n" : "\nListo.\n";
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Verificación de la identidad por BSUID (nombres de usuario de WhatsApp).
|
||||||
|
* Ejecutar: php test_bsuid.php
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../services/WhatsAppService.php';
|
||||||
|
|
||||||
|
$fallos = 0;
|
||||||
|
function chequear($descripcion, $condicion) {
|
||||||
|
global $fallos;
|
||||||
|
if ($condicion) { echo " ok $descripcion\n"; }
|
||||||
|
else { echo " FALLA $descripcion\n"; $fallos++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. Reconocer un BSUID y no confundirlo con un teléfono ---
|
||||||
|
chequear('reconoce el BSUID real de hoy', WhatsAppService::esBsuid('CO.1761088155094242'));
|
||||||
|
chequear('reconoce el BSUID largo de Meta', WhatsAppService::esBsuid('US.13491208655302741918'));
|
||||||
|
chequear('un celular NO es BSUID', !WhatsAppService::esBsuid('573214533764'));
|
||||||
|
chequear('vacío NO es BSUID', !WhatsAppService::esBsuid(''));
|
||||||
|
chequear('texto suelto NO es BSUID', !WhatsAppService::esBsuid('CO.abc'));
|
||||||
|
|
||||||
|
// --- 2. formatPhoneNumber no debe destrozar el BSUID ---
|
||||||
|
$svc = (new ReflectionClass('WhatsAppService'))->newInstanceWithoutConstructor();
|
||||||
|
$fmt = new ReflectionMethod('WhatsAppService', 'formatPhoneNumber');
|
||||||
|
$fmt->setAccessible(true);
|
||||||
|
|
||||||
|
chequear('el BSUID sale intacto',
|
||||||
|
$fmt->invoke($svc, 'CO.1761088155094242') === 'CO.1761088155094242');
|
||||||
|
chequear('el celular sin indicativo sigue recibiendo el 57',
|
||||||
|
$fmt->invoke($svc, '3214533764') === '573214533764');
|
||||||
|
chequear('el celular con indicativo se conserva',
|
||||||
|
$fmt->invoke($svc, '573214533764') === '573214533764');
|
||||||
|
|
||||||
|
// --- 3. El payload debe usar `recipient` para BSUID y `to` para teléfonos ---
|
||||||
|
// Se replica la transformación de sendMessage(), que es privada y hace red.
|
||||||
|
$transformar = function (array $payload) {
|
||||||
|
if (isset($payload['to']) && WhatsAppService::esBsuid($payload['to'])) {
|
||||||
|
$payload['recipient'] = $payload['to'];
|
||||||
|
unset($payload['to']);
|
||||||
|
}
|
||||||
|
return $payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
$conBsuid = $transformar(['to' => 'CO.1761088155094242', 'type' => 'text']);
|
||||||
|
chequear('con BSUID se envía en `recipient`', ($conBsuid['recipient'] ?? null) === 'CO.1761088155094242');
|
||||||
|
chequear('con BSUID desaparece `to`', !isset($conBsuid['to']));
|
||||||
|
|
||||||
|
$conTel = $transformar(['to' => '573214533764', 'type' => 'text']);
|
||||||
|
chequear('con teléfono se mantiene `to`', ($conTel['to'] ?? null) === '573214533764');
|
||||||
|
chequear('con teléfono no aparece `recipient`', !isset($conTel['recipient']));
|
||||||
|
|
||||||
|
// --- 4. El webhook debe extraer el remitente del payload real que llegó hoy ---
|
||||||
|
$payloadReal = json_decode('{"messaging_product":"whatsapp",
|
||||||
|
"contacts":[{"profile":{"name":"con la fe puesta en Dios","username":"Luzmarytosfer"},
|
||||||
|
"user_id":"CO.1761088155094242"}],
|
||||||
|
"messages":[{"from_user_id":"CO.1761088155094242","id":"wamid.XXX",
|
||||||
|
"timestamp":"1786481656","text":{"body":"Buenos dias"},"type":"text"}]}', true);
|
||||||
|
|
||||||
|
$m = $payloadReal['messages'][0];
|
||||||
|
$remitente = $m['from'] ?? ($m['wa_id'] ?? ($m['from_user_id'] ?? null));
|
||||||
|
chequear('el mensaje que se perdía ahora sí tiene remitente', $remitente === 'CO.1761088155094242');
|
||||||
|
|
||||||
|
$nombres = [];
|
||||||
|
foreach ($payloadReal['contacts'] as $c) {
|
||||||
|
$n = $c['profile']['name'] ?? null;
|
||||||
|
if (!$n) continue;
|
||||||
|
foreach ([$c['wa_id'] ?? null, $c['user_id'] ?? null] as $k) {
|
||||||
|
if ($k) $nombres[$k] = $n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chequear('se rescata el nombre aunque no haya wa_id',
|
||||||
|
($nombres['CO.1761088155094242'] ?? null) === 'con la fe puesta en Dios');
|
||||||
|
|
||||||
|
// --- 5. El identificador debe caber en la columna ---
|
||||||
|
chequear('el BSUID más largo cabe en varchar(32)', strlen('US.13491208655302741918') <= 32);
|
||||||
|
|
||||||
|
// --- 6. Extraer el teléfono del contacto compartido (webhook `contacts` de Meta) ---
|
||||||
|
$extraerTelefono = function (array $mensaje) {
|
||||||
|
foreach ($mensaje['contacts'] ?? [] as $c) {
|
||||||
|
foreach ($c['phones'] ?? [] as $t) {
|
||||||
|
$cand = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||||
|
if ($cand) return preg_replace('/[^0-9]/', '', $cand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
$porBoton = ['type' => 'contacts', 'from_user_id' => 'CO.1761088155094242',
|
||||||
|
'contacts' => [['origin' => 'contact_request',
|
||||||
|
'phones' => [['phone' => '+57 321 4533764', 'wa_id' => '573214533764', 'type' => 'CELL']]]]];
|
||||||
|
chequear('del botón se extrae el teléfono', $extraerTelefono($porBoton) === '573214533764');
|
||||||
|
|
||||||
|
// Compartido a mano: puede venir con vcard y sin wa_id
|
||||||
|
$aMano = ['type' => 'contacts', 'from_user_id' => 'CO.1761088155094242',
|
||||||
|
'contacts' => [['origin' => 'other', 'vcard' => 'BEGIN:VCARD...',
|
||||||
|
'phones' => [['phone' => '+57 321 453 3764', 'type' => 'CELL']]]]];
|
||||||
|
chequear('compartido a mano también se extrae', $extraerTelefono($aMano) === '573214533764');
|
||||||
|
chequear('sin teléfono devuelve nulo', $extraerTelefono(['type' => 'contacts', 'contacts' => [[]]]) === null);
|
||||||
|
|
||||||
|
// --- 7. El botón para pedir el contacto debe armarse como exige Meta ---
|
||||||
|
$pedir = new ReflectionMethod('WhatsAppService', 'pedirContacto');
|
||||||
|
chequear('pedirContacto existe y es pública', $pedir->isPublic());
|
||||||
|
|
||||||
|
$payloadBoton = [
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'recipient_type' => 'individual',
|
||||||
|
'to' => $fmt->invoke($svc, 'CO.1761088155094242'),
|
||||||
|
'type' => 'interactive',
|
||||||
|
'interactive' => ['type' => 'request_contact_info',
|
||||||
|
'body' => ['text' => 'texto'],
|
||||||
|
'action' => ['name' => 'request_contact_info']],
|
||||||
|
];
|
||||||
|
$enviado = $transformar($payloadBoton);
|
||||||
|
chequear('el botón viaja con `recipient` cuando es BSUID',
|
||||||
|
($enviado['recipient'] ?? null) === 'CO.1761088155094242' && !isset($enviado['to']));
|
||||||
|
chequear('el tipo interactivo es el que Meta espera',
|
||||||
|
$enviado['interactive']['type'] === 'request_contact_info'
|
||||||
|
&& $enviado['interactive']['action']['name'] === 'request_contact_info');
|
||||||
|
|
||||||
|
// --- 8. La ficha del paciente no debe recibir el BSUID como teléfono ---
|
||||||
|
// Réplica de la decisión de Paciente::obtenerOCrearDesdeWhatsapp()
|
||||||
|
$fichaDesde = function ($phoneNumber, $nombre = null) {
|
||||||
|
$sinTelefono = esBsuid($phoneNumber);
|
||||||
|
return [
|
||||||
|
'nombre_completo' => $nombre ?? ($sinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $phoneNumber),
|
||||||
|
'telefono' => $sinTelefono ? null : $phoneNumber,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
$fichaOculta = $fichaDesde('CO.1761088155094242');
|
||||||
|
chequear('la ficha queda SIN teléfono si es BSUID', $fichaOculta['telefono'] === null);
|
||||||
|
chequear('y sin el identificador metido en el nombre',
|
||||||
|
strpos($fichaOculta['nombre_completo'], 'CO.') === false);
|
||||||
|
|
||||||
|
$fichaNormal = $fichaDesde('573214533764');
|
||||||
|
chequear('con teléfono real la ficha lo conserva', $fichaNormal['telefono'] === '573214533764');
|
||||||
|
chequear('y el nombre por defecto no cambia', $fichaNormal['nombre_completo'] === 'Paciente 573214533764');
|
||||||
|
|
||||||
|
// El daño concreto que se evita: normalizarTelefono() dejaba 16 dígitos con
|
||||||
|
// toda la pinta de un número real dentro de la historia clínica.
|
||||||
|
$comoQuedaba = preg_replace('/[^0-9+]/', '', 'CO.1761088155094242');
|
||||||
|
chequear('antes se guardaba un número falso creíble', $comoQuedaba === '1761088155094242');
|
||||||
|
chequear('y ahora eso ya no llega al campo teléfono', $fichaOculta['telefono'] !== $comoQuedaba);
|
||||||
|
|
||||||
|
// --- 9. Solo se pide el contacto a quien hace falta, y una sola vez ---
|
||||||
|
$decidir = function ($phoneNumber, $pedidoAt) {
|
||||||
|
if (!esBsuid($phoneNumber)) return 'no_hace_falta';
|
||||||
|
if (!empty($pedidoAt)) return 'ya_se_pidio';
|
||||||
|
return 'pedido';
|
||||||
|
};
|
||||||
|
chequear('a quien tiene teléfono no se le pide', $decidir('573214533764', null) === 'no_hace_falta');
|
||||||
|
chequear('a quien lo oculta sí se le pide', $decidir('CO.1761088155094242', null) === 'pedido');
|
||||||
|
chequear('y no se le insiste una segunda vez',
|
||||||
|
$decidir('CO.1761088155094242', '2026-08-11 16:00:00') === 'ya_se_pidio');
|
||||||
|
|
||||||
|
// --- 10. La comprobación vive en un solo sitio ---
|
||||||
|
chequear('esBsuid() global y el del servicio coinciden',
|
||||||
|
esBsuid('CO.1761088155094242') === WhatsAppService::esBsuid('CO.1761088155094242')
|
||||||
|
&& esBsuid('573214533764') === WhatsAppService::esBsuid('573214533764'));
|
||||||
|
|
||||||
|
echo $fallos === 0 ? "\nTodo correcto.\n" : "\n$fallos verificaciones fallaron.\n";
|
||||||
|
exit($fallos === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* scripts/test_rips_sondeo.js — node scripts/test_rips_sondeo.js
|
||||||
|
*
|
||||||
|
* Pruebas del sondeo RIPS contra el código real de recepcion.php.
|
||||||
|
*
|
||||||
|
* El defecto que esto vigila: la consulta a RIPS se hacía UNA vez al vincular
|
||||||
|
* al paciente, y en 7 días 547 de 549 registros llegaron después de esa única
|
||||||
|
* consulta. El sondeo reintenta cada 10 s hasta 10 minutos y debe detenerse
|
||||||
|
* al encontrar, al limpiar la ficha, o al agotar los intentos.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
let fallos = 0;
|
||||||
|
const ok = (d, c) => { console.log((c ? ' ok ' : ' FALLA ') + d); if (!c) fallos++; };
|
||||||
|
|
||||||
|
const src = fs.readFileSync(__dirname + '/../modules/turnero/views/recepcion.php', 'utf8')
|
||||||
|
.replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"');
|
||||||
|
|
||||||
|
// Se extraen exactamente las tres piezas del control de sondeo
|
||||||
|
const m = src.match(/let _ripsSondeoId[\s\S]*?function detenerSondeoRips\(\) \{[\s\S]*?\n\}/);
|
||||||
|
if (!m) { console.log(' FALLA no se encontró el bloque de sondeo'); process.exit(1); }
|
||||||
|
|
||||||
|
function armar() {
|
||||||
|
let consultas = 0;
|
||||||
|
const intervalos = new Map();
|
||||||
|
let proximoId = 1;
|
||||||
|
const setIntervalFake = (fn, ms) => { const id = proximoId++; intervalos.set(id, { fn, ms }); return id; };
|
||||||
|
const clearIntervalFake = (id) => intervalos.delete(id);
|
||||||
|
|
||||||
|
const api = new Function(
|
||||||
|
'setInterval', 'clearInterval', 'INTERVALOS', 'CONTADOR',
|
||||||
|
'let _ripsData = null;' +
|
||||||
|
'const consultarExamenesRips = (c) => { CONTADOR.n++; };' +
|
||||||
|
m[0] + `;
|
||||||
|
return {
|
||||||
|
iniciar: iniciarSondeoRips,
|
||||||
|
detener: detenerSondeoRips,
|
||||||
|
tic() { for (const v of [...INTERVALOS.values()]) v.fn(); },
|
||||||
|
activo() { return _ripsSondeoId !== null; },
|
||||||
|
encontrado() { _ripsData = { encontrados: [1] }; },
|
||||||
|
};`
|
||||||
|
)(setIntervalFake, clearIntervalFake, intervalos, (globalThis.__c = { n: 0 }));
|
||||||
|
return { api, contador: globalThis.__c, intervalos };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Consulta de inmediato y reintenta ────────────────────────────────
|
||||||
|
{
|
||||||
|
const { api, contador } = armar();
|
||||||
|
api.iniciar('12345678');
|
||||||
|
ok('consulta de inmediato al vincular', contador.n === 1);
|
||||||
|
api.tic(); api.tic(); api.tic();
|
||||||
|
ok('reintenta en cada ciclo mientras no encuentra', contador.n === 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Al encontrar, deja de sondear ────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { api, contador } = armar();
|
||||||
|
api.iniciar('12345678');
|
||||||
|
api.tic();
|
||||||
|
api.encontrado();
|
||||||
|
api.tic(); // este ciclo detecta _ripsData y se apaga
|
||||||
|
const n = contador.n;
|
||||||
|
api.tic(); api.tic();
|
||||||
|
ok('al encontrar los exámenes se apaga y no consulta más',
|
||||||
|
contador.n === n && !api.activo());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Limpiar la ficha lo detiene ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { api, contador } = armar();
|
||||||
|
api.iniciar('12345678');
|
||||||
|
api.detener();
|
||||||
|
const n = contador.n;
|
||||||
|
api.tic(); api.tic();
|
||||||
|
ok('limpiar la ficha detiene el sondeo en seco', contador.n === n && !api.activo());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Tope de intentos: no sondea para siempre ─────────────────────────
|
||||||
|
{
|
||||||
|
const { api, contador } = armar();
|
||||||
|
api.iniciar('12345678');
|
||||||
|
for (let i = 0; i < 80; i++) api.tic();
|
||||||
|
ok('se rinde tras ~60 intentos (10 min), no queda sondeando eternamente',
|
||||||
|
contador.n <= 61 && !api.activo());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Vincular a otro paciente reinicia sin duplicar intervalos ────────
|
||||||
|
{
|
||||||
|
const { api, contador, intervalos } = armar();
|
||||||
|
api.iniciar('11111111');
|
||||||
|
api.iniciar('22222222');
|
||||||
|
ok('re-vincular no deja dos sondeos corriendo a la vez', intervalos.size === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Sin cédula no arranca ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { api, contador } = armar();
|
||||||
|
api.iniciar('');
|
||||||
|
ok('sin cédula no consulta ni deja intervalos', contador.n === 0 && !api.activo());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Guardián de ficha: una respuesta tardía no aterriza en otro paciente ──
|
||||||
|
// La respuesta del fetch puede llegar DESPUÉS de que la recepcionista cambió
|
||||||
|
// de ficha. Este caso vigila que el código compare la cédula de la respuesta
|
||||||
|
// contra la de quien está en pantalla ANTES de cargar nada. Es estructural
|
||||||
|
// (el código real usa fetch y DOM), pero si alguien borra el guardián, falla.
|
||||||
|
{
|
||||||
|
const i = src.indexOf('get_examenes_rips.php?cedula=');
|
||||||
|
const j = src.indexOf('cedulaEnPantalla', i);
|
||||||
|
const k = src.indexOf("d.fuente === 'cache'", i);
|
||||||
|
ok('el guardián de cédula existe y corre ANTES de cargar los exámenes',
|
||||||
|
i > -1 && j > -1 && k > -1 && j < k);
|
||||||
|
ok('el guardián descarta y detiene el sondeo si la ficha cambió',
|
||||||
|
/cedulaEnPantalla !== cedula\) \{ detenerSondeoRips\(\); return; \}/.test(src));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
|
||||||
|
process.exit(fallos === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* scripts/test_rips_ventana.php — php scripts/test_rips_ventana.php
|
||||||
|
*
|
||||||
|
* Prueba la VENTANA de la caché RIPS (la consulta de get_examenes_rips.php)
|
||||||
|
* contra la base real, en una tabla TEMPORARY que se esfuma al desconectar:
|
||||||
|
* ni toca ni ensucia los datos de producción.
|
||||||
|
*
|
||||||
|
* Reglas que vigila:
|
||||||
|
* - un registro de hace 5 minutos se encuentra;
|
||||||
|
* - uno de hace más de 30 minutos NO (ventana);
|
||||||
|
* - uno ya asignado a un turno NO (turno_id);
|
||||||
|
* - uno de ayer NO (solo el día actual);
|
||||||
|
* - de dos del mismo documento gana el más reciente.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
$fallos = 0;
|
||||||
|
function ok(string $d, bool $c): void {
|
||||||
|
global $fallos;
|
||||||
|
echo ($c ? ' ok ' : ' FALLA ') . $d . "\n";
|
||||||
|
if (!$c) $fallos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
|
||||||
|
$pdo->exec("CREATE TEMPORARY TABLE tmp_rips LIKE rips_examenes_pendientes");
|
||||||
|
|
||||||
|
$ins = $pdo->prepare(
|
||||||
|
"INSERT INTO tmp_rips (numero_documento, datos, recepcion_id, hora_recepcion, created_at, turno_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
// (documento, minutos hacia atrás de created_at, turno_id)
|
||||||
|
$casos = [
|
||||||
|
['1111', 5, null], // reciente y libre → debe aparecer
|
||||||
|
['2222', 45, null], // fuera de la ventana de 30 min
|
||||||
|
['3333', 5, 999], // ya usado por un turno
|
||||||
|
['4444', 1500, null], // de ayer
|
||||||
|
['5555', 20, null], // mismo doc, dos registros…
|
||||||
|
['5555', 3, null], // …gana el más reciente (marcado v2)
|
||||||
|
];
|
||||||
|
foreach ($casos as $i => [$doc, $min, $turno]) {
|
||||||
|
$ins->execute([
|
||||||
|
$doc,
|
||||||
|
json_encode([['cod_examen' => 'T' . $i, 'v' => $i === 5 ? 'v2' : 'v1']]),
|
||||||
|
1000 + $i,
|
||||||
|
'08:00:00',
|
||||||
|
date('Y-m-d H:i:s', time() - $min * 60),
|
||||||
|
$turno,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// La MISMA consulta de get_examenes_rips.php, apuntada a la tabla temporal
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT datos FROM tmp_rips
|
||||||
|
WHERE numero_documento = ?
|
||||||
|
AND DATE(created_at) = CURDATE()
|
||||||
|
AND created_at >= NOW() - INTERVAL 30 MINUTE
|
||||||
|
AND turno_id IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$buscar = function (string $doc) use ($q) {
|
||||||
|
$q->execute([$doc]);
|
||||||
|
$r = $q->fetch(PDO::FETCH_ASSOC);
|
||||||
|
return $r ? json_decode($r['datos'], true) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
ok('un registro de hace 5 minutos se encuentra', $buscar('1111') !== null);
|
||||||
|
ok('uno de hace 45 minutos queda fuera de la ventana', $buscar('2222') === null);
|
||||||
|
ok('uno ya asignado a un turno no se reutiliza', $buscar('3333') === null);
|
||||||
|
ok('uno de ayer no aparece aunque el documento coincida', $buscar('4444') === null);
|
||||||
|
$r = $buscar('5555');
|
||||||
|
ok('con dos del mismo documento gana el más reciente', ($r[0]['v'] ?? '') === 'v2');
|
||||||
|
|
||||||
|
// El re-empuje del scheduler (DELETE + INSERT) refresca created_at, así que un
|
||||||
|
// registro viejo que el sistema legado reenvía vuelve a entrar en la ventana.
|
||||||
|
$pdo->exec("DELETE FROM tmp_rips WHERE numero_documento = '2222'");
|
||||||
|
$ins->execute(['2222', json_encode([['cod_examen' => 'RE']]), 2000, '08:00:00',
|
||||||
|
date('Y-m-d H:i:s'), null]);
|
||||||
|
ok('un re-empuje del scheduler vuelve a hacer visible el registro', $buscar('2222') !== null);
|
||||||
|
|
||||||
|
echo "\n" . ($fallos === 0 ? "Todo correcto.\n" : "$fallos pruebas fallaron.\n");
|
||||||
|
exit($fallos === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* scripts/test_tv_voz.js — node scripts/test_tv_voz.js
|
||||||
|
*
|
||||||
|
* Pruebas de la voz del televisor CONTRA EL CÓDIGO REAL: se extrae el bloque
|
||||||
|
* de anuncios de display_global.php y se ejecuta con un sintetizador simulado.
|
||||||
|
* Cubre los tres fallos intermitentes diagnosticados:
|
||||||
|
* 1. recolector de basura llevándose la locución a mitad de frase
|
||||||
|
* 2. motor atascado en pausa tras un cancel() (silencio total sin error)
|
||||||
|
* 3. voz que nunca arranca (bloqueo o voz rota) dejando el cartel pegado
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
let fallos = 0;
|
||||||
|
function ok(desc, cond) {
|
||||||
|
console.log((cond ? ' ok ' : ' FALLA ') + desc);
|
||||||
|
if (!cond) fallos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extraer el código real ──────────────────────────────────────────────
|
||||||
|
const src = fs.readFileSync(__dirname + '/../modules/turnero/views/display_global.php', 'utf8')
|
||||||
|
.replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"');
|
||||||
|
const js = src.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1];
|
||||||
|
const ini = js.indexOf('const VOCES_PREFERIDAS');
|
||||||
|
const fin = js.indexOf('/* ── Fullscreen ── */');
|
||||||
|
const cuerpo = 'let sonidoActivo=true,_vozES=null;function playBeep(){}'
|
||||||
|
+ js.slice(ini, fin);
|
||||||
|
|
||||||
|
ok('el código extraído contiene el anclaje anti-recolector', cuerpo.includes('_uttAncladas'));
|
||||||
|
ok('resume() se llama antes de cada speak()',
|
||||||
|
/resume\(\);\s*\n\s*window\.speechSynthesis\.speak\(utt\)/.test(cuerpo));
|
||||||
|
|
||||||
|
ok('el despertador SSE está conectado (los llamados despiertan a la pestaña oculta)',
|
||||||
|
js.includes("sse_turno.php?area=recepcion") && js.includes("addEventListener('cola_update'"));
|
||||||
|
|
||||||
|
// ── Armazón de simulación ───────────────────────────────────────────────
|
||||||
|
function escenario({ bloqueado = false, msArranque = 20, caida = false, atascoUnaVez = false } = {}) {
|
||||||
|
let t = 0; const cola = [];
|
||||||
|
const st = (fn, ms) => cola.push({ t: t + ms, fn });
|
||||||
|
class Utt {
|
||||||
|
constructor(x) { this.texto = x; this.l = {}; }
|
||||||
|
addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); }
|
||||||
|
emit(e, arg) { (this.l[e] || []).forEach(f => f(arg || {})); }
|
||||||
|
}
|
||||||
|
let resumeAntesDeSpeak = false, resumePendiente = false;
|
||||||
|
let activa = null, totalSpeaks = 0;
|
||||||
|
const synth = {
|
||||||
|
speaking: false, pending: false,
|
||||||
|
pause() {},
|
||||||
|
// Como Chrome de verdad: cancelar una locución activa le dispara
|
||||||
|
// 'error' con interrupted. Fue exactamente el comportamiento que el
|
||||||
|
// simulador viejo no imitaba, y por eso no cazó el doble anuncio.
|
||||||
|
cancel() {
|
||||||
|
synth.speaking = synth.pending = false;
|
||||||
|
const u = activa; activa = null;
|
||||||
|
if (u) u.emit('error', { error: 'interrupted' });
|
||||||
|
},
|
||||||
|
resume() { resumePendiente = true; },
|
||||||
|
getVoices: () => [{ name: 'Sabina', lang: 'es-MX', localService: true }],
|
||||||
|
speak(u) {
|
||||||
|
resumeAntesDeSpeak = resumePendiente; resumePendiente = false;
|
||||||
|
totalSpeaks++;
|
||||||
|
if (bloqueado || caida) return; // descartado en silencio: ni pending queda
|
||||||
|
if (atascoUnaVez && totalSpeaks === 1) {
|
||||||
|
// El modo verificado en vivo (23/08): el motor JURA hablar
|
||||||
|
// (speaking=true) pero no suena y 'start' no llega jamás.
|
||||||
|
activa = u; synth.speaking = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activa = u;
|
||||||
|
synth.pending = true;
|
||||||
|
st(() => { if (activa !== u) return; synth.pending = false; synth.speaking = true; u.emit('start'); }, msArranque);
|
||||||
|
st(() => { if (activa !== u) return; synth.speaking = false; activa = null; u.emit('end'); }, msArranque + 4000);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const win = { speechSynthesis: synth };
|
||||||
|
const beacons = [];
|
||||||
|
const sandbox = new Function(
|
||||||
|
'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
|
||||||
|
'localStorage', 'navigator', 'Blob', 'Date', 'document',
|
||||||
|
cuerpo + '; return { anunciarTurno, _uttAncladas };'
|
||||||
|
);
|
||||||
|
const fakeDate = { now: () => t };
|
||||||
|
fakeDate.prototype = Date.prototype;
|
||||||
|
const api = sandbox(
|
||||||
|
win, st, () => {}, Utt,
|
||||||
|
{ getItem: () => '[]', setItem: () => {} },
|
||||||
|
{ sendBeacon: (u, b) => beacons.push(u) },
|
||||||
|
function Blob() {},
|
||||||
|
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }),
|
||||||
|
{ addEventListener: () => {}, hidden: false }
|
||||||
|
);
|
||||||
|
const correr = (hastaMs) => {
|
||||||
|
let n = 0;
|
||||||
|
while (cola.length && n++ < 500) {
|
||||||
|
cola.sort((a, b) => a.t - b.t);
|
||||||
|
const e = cola.shift();
|
||||||
|
if (hastaMs !== undefined && e.t > hastaMs) { cola.unshift(e); break; }
|
||||||
|
t = e.t; e.fn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { api, correr, beacons, resumeUsado: () => resumeAntesDeSpeak, t: () => t,
|
||||||
|
speaks: () => totalSpeaks, hablando: () => activa };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Anclaje: la locución queda referenciada mientras habla ───────────
|
||||||
|
{
|
||||||
|
const e = escenario();
|
||||||
|
e.api.anunciarTurno('A01', 'Consultorio 2', null, () => {});
|
||||||
|
e.correr(800); // ya se llamó a speak()
|
||||||
|
ok('durante el habla la locución está anclada (no la puede recoger el GC)',
|
||||||
|
e.api._uttAncladas.size === 1);
|
||||||
|
e.correr(); // hasta el final
|
||||||
|
ok('al terminar se suelta el anclaje (sin fuga de memoria)',
|
||||||
|
e.api._uttAncladas.size === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. resume() antes de speak: motor atascado en pausa ─────────────────
|
||||||
|
{
|
||||||
|
const e = escenario();
|
||||||
|
e.api.anunciarTurno('A02', 'Consultorio 1', null, () => {});
|
||||||
|
e.correr();
|
||||||
|
ok('speak() siempre va precedido de resume() (destranca el motor pausado)',
|
||||||
|
e.resumeUsado());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Audio bloqueado: avisa para que el cartel no quede pegado ────────
|
||||||
|
{
|
||||||
|
const e = escenario({ bloqueado: true });
|
||||||
|
let aviso = null;
|
||||||
|
e.api.anunciarTurno('A03', 'Consultorio 3', null, () => { aviso = e.t(); });
|
||||||
|
e.correr();
|
||||||
|
ok('con el audio bloqueado igual avisa y el cartel se puede cerrar',
|
||||||
|
aviso !== null && aviso < 10000);
|
||||||
|
ok('el anclaje también se suelta cuando no llegó a hablar',
|
||||||
|
e.api._uttAncladas.size === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Voz lenta (remota): no se corta ──────────────────────────────────
|
||||||
|
{
|
||||||
|
const e = escenario({ msArranque: 2800 });
|
||||||
|
let termino = false;
|
||||||
|
e.api.anunciarTurno('A04', 'Consultorio 4', 'maría gómez', () => { termino = true; });
|
||||||
|
e.correr();
|
||||||
|
ok('una voz que tarda 2,8 s en arrancar habla completa y termina', termino);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. La bitácora registra start y end ─────────────────────────────────
|
||||||
|
{
|
||||||
|
const e = escenario();
|
||||||
|
e.api.anunciarTurno('A05', 'Consultorio 5', null, () => {});
|
||||||
|
e.correr();
|
||||||
|
ok('la bitácora mandó al menos 2 eventos (start y end) al servidor',
|
||||||
|
e.beacons.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5b. La voz no arranca: reintenta UNA vez, sin doble anuncio ─────────
|
||||||
|
// El defecto que esto vigila: cancel() dispara 'error' en la locución vieja,
|
||||||
|
// y sin el token de generación ese handler lanzaba OTRO reintento: dos voces
|
||||||
|
// superpuestas diciendo lo mismo, de forma intermitente.
|
||||||
|
{
|
||||||
|
// Voz que queda pendiente pero jamás arranca (pending eterno no: el sim
|
||||||
|
// bloqueado ni encola — usamos arranque infinito quitando la cola de start)
|
||||||
|
// Modo real de Chrome: el speak se descarta EN SILENCIO (ni speaking ni
|
||||||
|
// pending). Ese es el caso que el vigilante reintenta. El otro modo —voz
|
||||||
|
// eternamente en pending— se tolera a propósito: es indistinguible de una
|
||||||
|
// voz remota lenta, y cortarla fue justo el defecto del entrecortado.
|
||||||
|
const e = escenario({ caida: true });
|
||||||
|
let avisos = 0;
|
||||||
|
e.api.anunciarTurno('C01', 'Consultorio 1', null, () => { avisos++; });
|
||||||
|
e.correr(30000);
|
||||||
|
ok('voz descartada en silencio: exactamente 2 speak (original + 1 reintento), no más',
|
||||||
|
e.speaks() === 2);
|
||||||
|
ok('y el cartel recibe exactamente UN aviso de cierre (vía «mudo»)', avisos === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5c. Un anuncio nuevo que cancela al anterior no lo resucita ─────────
|
||||||
|
{
|
||||||
|
const e = escenario({ msArranque: 20 });
|
||||||
|
let avisosA = 0;
|
||||||
|
e.api.anunciarTurno('D01', 'Consultorio 1', null, () => { avisosA++; });
|
||||||
|
e.correr(1000); // D01 está hablando
|
||||||
|
const speaksAntes = e.speaks();
|
||||||
|
e.api.anunciarTurno('D02', 'Consultorio 2', null, () => {});
|
||||||
|
e.correr();
|
||||||
|
ok('cancelar al anterior con un anuncio nuevo no dispara reintentos fantasma del viejo',
|
||||||
|
e.speaks() === speaksAntes + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5d. MOTOR ATASCADO: speaking sin 'start' → romperlo y rehablar ──────
|
||||||
|
// Reproducido en vivo el 23/08: primer llamado tras el ocio, el motor queda
|
||||||
|
// en speaking=true sin audio. El vigilante debe detectarlo (una locución que
|
||||||
|
// de verdad suena dispara 'start' de inmediato), romperlo y rehablar — una
|
||||||
|
// sola vez, sin dobles.
|
||||||
|
{
|
||||||
|
const e = escenario({ atascoUnaVez: true });
|
||||||
|
let avisos = 0;
|
||||||
|
e.api.anunciarTurno('F01', 'Consultorio 1', null, () => { avisos++; });
|
||||||
|
e.correr();
|
||||||
|
ok('el atasco se detecta y el reintento habla: exactamente 2 speak',
|
||||||
|
e.speaks() === 2);
|
||||||
|
ok('el llamado termina avisando UNA vez (el reintento habló completo)',
|
||||||
|
avisos === 1);
|
||||||
|
ok('sin locuciones ancladas al final (sin fugas tras el atasco)',
|
||||||
|
e.api._uttAncladas.size === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5e. Keepalive mudo: el motor recibe trabajo inaudible en el ocio ────
|
||||||
|
// pause/resume no mantiene despierto al motor (verificado en vivo); hablar a
|
||||||
|
// volumen cero sí. Este caso vigila que el keepalive exista y sea inaudible.
|
||||||
|
{
|
||||||
|
const intervalos = [];
|
||||||
|
let t = 0; const cola = [];
|
||||||
|
const st = (fn, ms) => cola.push({ t: t + ms, fn });
|
||||||
|
class Utt { constructor(x) { this.texto = x; this.l = {}; } addEventListener() {} }
|
||||||
|
let hablado = null;
|
||||||
|
const synth = { speaking: false, pending: false, pause() {}, cancel() {}, resume() {},
|
||||||
|
getVoices: () => [], speak(u) { hablado = u; } };
|
||||||
|
new Function('window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
|
||||||
|
'localStorage', 'navigator', 'Blob', 'Date', 'document',
|
||||||
|
cuerpo + ';')(
|
||||||
|
{ speechSynthesis: synth }, st, (fn, ms) => intervalos.push({ fn, ms }), Utt,
|
||||||
|
{ getItem: () => '[]', setItem: () => {} }, { sendBeacon: () => {} },
|
||||||
|
function Blob() {},
|
||||||
|
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => 0 }),
|
||||||
|
{ addEventListener: () => {}, hidden: false });
|
||||||
|
const ka = intervalos.find(i => i.ms === 25000);
|
||||||
|
ok('existe el keepalive de 25 s', !!ka);
|
||||||
|
if (ka) ka.fn();
|
||||||
|
ok('en el ocio habla una letra a VOLUMEN CERO (inaudible, pero trabajo real)',
|
||||||
|
hablado !== null && hablado.volume === 0);
|
||||||
|
ok('con el motor ocupado el keepalive no interfiere', (() => {
|
||||||
|
hablado = null; synth.speaking = true; if (ka) ka.fn(); return hablado === null;
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. El camino feliz: habla bien ──────────────────────────────────────
|
||||||
|
// Qué dice exactamente, y con qué voz. Un anuncio que "funciona" pero
|
||||||
|
// pronuncia mal el nombre o elige la voz equivocada también es un fallo.
|
||||||
|
{
|
||||||
|
// Instrumentación directa: escenario propio con captura de la locución
|
||||||
|
let t = 0; const cola = [];
|
||||||
|
const st = (fn, ms) => cola.push({ t: t + ms, fn });
|
||||||
|
class Utt {
|
||||||
|
constructor(x) { this.texto = x; this.l = {}; }
|
||||||
|
addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); }
|
||||||
|
emit(e, a) { (this.l[e] || []).forEach(f => f(a || {})); }
|
||||||
|
}
|
||||||
|
let capturada = null;
|
||||||
|
const synth = {
|
||||||
|
speaking: false, pending: false, pause() {}, cancel() {}, resume() {},
|
||||||
|
getVoices: () => [
|
||||||
|
{ name: 'Microsoft Sabina', lang: 'es-MX', localService: true },
|
||||||
|
{ name: 'Google español de Colombia', lang: 'es-CO', localService: false },
|
||||||
|
],
|
||||||
|
speak(u) {
|
||||||
|
capturada = u;
|
||||||
|
st(() => u.emit('start'), 10);
|
||||||
|
st(() => u.emit('end'), 4000);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const api = new Function(
|
||||||
|
'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
|
||||||
|
'localStorage', 'navigator', 'Blob', 'Date', 'document',
|
||||||
|
cuerpo + '; return { anunciarTurno };'
|
||||||
|
)({ speechSynthesis: synth }, st, () => {}, Utt,
|
||||||
|
{ getItem: () => '[]', setItem: () => {} },
|
||||||
|
{ sendBeacon: () => {} }, function Blob() {},
|
||||||
|
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }),
|
||||||
|
{ addEventListener: () => {}, hidden: false });
|
||||||
|
|
||||||
|
api.anunciarTurno('A01', 'Consultorio 2', 'maría fernanda gómez', () => {});
|
||||||
|
let n = 0;
|
||||||
|
while (cola.length && n++ < 200) { cola.sort((a, b) => a.t - b.t); const ev = cola.shift(); t = ev.t; ev.fn(); }
|
||||||
|
|
||||||
|
ok('el texto deletrea el código y termina con el destino',
|
||||||
|
capturada && capturada.texto === 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2');
|
||||||
|
ok('los nombres con tilde salen bien capitalizados (María, no MaríA)',
|
||||||
|
capturada && capturada.texto.includes('María Fernanda Gómez'));
|
||||||
|
ok('elige la voz LOCAL (Sabina) aunque la remota es-CO esté disponible',
|
||||||
|
capturada && capturada.voice && capturada.voice.name === 'Microsoft Sabina');
|
||||||
|
ok('sin nombre de paciente igual habla: solo código y destino', (() => {
|
||||||
|
capturada = null;
|
||||||
|
api.anunciarTurno('B02', 'Recepción 1', null, () => {});
|
||||||
|
let m = 0;
|
||||||
|
while (cola.length && m++ < 200) { cola.sort((a, b) => a.t - b.t); const ev = cola.shift(); t = ev.t; ev.fn(); }
|
||||||
|
return capturada && capturada.texto === 'Turno B 0 2, pase a Recepción 1';
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
|
||||||
|
process.exit(fallos === 0 ? 0 : 1);
|
||||||
@@ -238,6 +238,32 @@ class WhatsAppService
|
|||||||
/**
|
/**
|
||||||
* Enviar mensaje con botones interactivos
|
* Enviar mensaje con botones interactivos
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Pedirle a la persona que comparta su número de teléfono.
|
||||||
|
*
|
||||||
|
* Quien activó el nombre de usuario de WhatsApp llega sin teléfono, y sin él
|
||||||
|
* no se le puede cruzar con su historia ni con el turnero. Este botón es la
|
||||||
|
* vía que da Meta para pedírselo: la persona decide si lo comparte, y si
|
||||||
|
* acepta llega un mensaje de tipo `contacts` con el número.
|
||||||
|
*
|
||||||
|
* Solo tiene sentido dentro de la ventana de 24 horas; fuera de ella hay que
|
||||||
|
* usar una plantilla con el botón REQUEST_CONTACT_INFO.
|
||||||
|
*/
|
||||||
|
public function pedirContacto($to, $bodyText)
|
||||||
|
{
|
||||||
|
return $this->sendMessage([
|
||||||
|
'messaging_product' => 'whatsapp',
|
||||||
|
'recipient_type' => 'individual',
|
||||||
|
'to' => $this->formatPhoneNumber($to),
|
||||||
|
'type' => 'interactive',
|
||||||
|
'interactive' => [
|
||||||
|
'type' => 'request_contact_info',
|
||||||
|
'body' => ['text' => $bodyText],
|
||||||
|
'action' => ['name' => 'request_contact_info'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function sendInteractiveMessage($to, $bodyText, $buttons, $header = null, $footer = null)
|
public function sendInteractiveMessage($to, $bodyText, $buttons, $header = null, $footer = null)
|
||||||
{
|
{
|
||||||
$interactive = [
|
$interactive = [
|
||||||
@@ -767,6 +793,14 @@ class WhatsAppService
|
|||||||
unset($payload['__app_meta']);
|
unset($payload['__app_meta']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A quien oculta su teléfono se le responde por BSUID, y Meta exige que vaya
|
||||||
|
// en `recipient` en lugar de `to`. Se corrige aquí, que es por donde pasan
|
||||||
|
// todos los envíos, en vez de en cada método que arma un payload.
|
||||||
|
if (isset($payload['to']) && self::esBsuid($payload['to'])) {
|
||||||
|
$payload['recipient'] = $payload['to'];
|
||||||
|
unset($payload['to']);
|
||||||
|
}
|
||||||
|
|
||||||
// Debug log (payload sent to WhatsApp)
|
// Debug log (payload sent to WhatsApp)
|
||||||
error_log("WhatsApp API URL: " . $url);
|
error_log("WhatsApp API URL: " . $url);
|
||||||
error_log("WhatsApp Token length: " . strlen($this->token));
|
error_log("WhatsApp Token length: " . strlen($this->token));
|
||||||
@@ -881,11 +915,27 @@ class WhatsAppService
|
|||||||
return $decoded;
|
return $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ¿El destinatario es un BSUID (identificador de usuario por empresa) y no un teléfono?
|
||||||
|
* La definición vive en config.php, que es donde la ven también las clases del
|
||||||
|
* laboratorio; aquí solo se expone para quien ya tiene el servicio a la mano.
|
||||||
|
*/
|
||||||
|
public static function esBsuid($destinatario)
|
||||||
|
{
|
||||||
|
return esBsuid($destinatario);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formatear número de teléfono
|
* Formatear número de teléfono
|
||||||
*/
|
*/
|
||||||
private function formatPhoneNumber($phone)
|
private function formatPhoneNumber($phone)
|
||||||
{
|
{
|
||||||
|
// Un BSUID no es un teléfono: se devuelve intacto, porque quitarle
|
||||||
|
// el punto y las letras lo convertiría en un destinatario inexistente.
|
||||||
|
if (self::esBsuid($phone)) {
|
||||||
|
return $phone;
|
||||||
|
}
|
||||||
|
|
||||||
// Remover caracteres especiales
|
// Remover caracteres especiales
|
||||||
$phone = preg_replace('/[^0-9]/', '', $phone);
|
$phone = preg_replace('/[^0-9]/', '', $phone);
|
||||||
|
|
||||||
|
|||||||
@@ -696,9 +696,14 @@ function _addExamWizardHtml(string $cid): string {
|
|||||||
.campo-linked { display: flex; gap: 16px; padding: 6px 0;
|
.campo-linked { display: flex; gap: 16px; padding: 6px 0;
|
||||||
border-bottom: 1px solid #f0f0f0; }
|
border-bottom: 1px solid #f0f0f0; }
|
||||||
.campo-linked-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; }
|
.campo-linked-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; }
|
||||||
.lnk-corregir { background:none; border:none; color:#94a3b8; cursor:pointer;
|
/* Antes era solo un lápiz gris de 11px: estaba, pero nadie lo encontraba.
|
||||||
padding:0 4px; font-size:11px; flex-shrink:0; }
|
Lleva texto y contorno para que se lea como lo que es, un botón. */
|
||||||
.lnk-corregir:hover { color:#1565c0; }
|
.lnk-corregir { background:none; border:none; border-radius:4px;
|
||||||
|
color:#1565c0; cursor:pointer; padding:1px 5px; font-size:10px;
|
||||||
|
font-weight:600; white-space:nowrap; flex-shrink:0;
|
||||||
|
display:inline-flex; align-items:center; gap:3px;
|
||||||
|
text-decoration:underline; }
|
||||||
|
.lnk-corregir:hover { background:#1565c0; border-color:#1565c0; color:#fff; }
|
||||||
.campo-linked-valor { flex: 1; font-size: 13px; font-weight: 600;
|
.campo-linked-valor { flex: 1; font-size: 13px; font-weight: 600;
|
||||||
color: #1565c0; }
|
color: #1565c0; }
|
||||||
|
|
||||||
@@ -1289,8 +1294,8 @@ function _addExamWizardHtml(string $cid): string {
|
|||||||
<div class="campo-linked-valor"><?= esc2($lval) ?></div>
|
<div class="campo-linked-valor"><?= esc2($lval) ?></div>
|
||||||
<?php if ($modoTurnero && $embebido && $modoEditar && !$_lnkBtnPuesto): $_lnkBtnPuesto = true; ?>
|
<?php if ($modoTurnero && $embebido && $modoEditar && !$_lnkBtnPuesto): $_lnkBtnPuesto = true; ?>
|
||||||
<button type="button" class="lnk-corregir no-print" onclick="pedirCorregirPaciente()"
|
<button type="button" class="lnk-corregir no-print" onclick="pedirCorregirPaciente()"
|
||||||
title="Corregir los datos del paciente">
|
title="Abrir la ficha del paciente para corregir sus datos">
|
||||||
<i class="fas fa-pen"></i>
|
<i class="fas fa-pen" style="font-size:9px"></i>Corregir
|
||||||
</button>
|
</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -2302,6 +2307,8 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var datos = json.datos_respuestas || {};
|
var datos = json.datos_respuestas || {};
|
||||||
|
var tocados = [], llenos = 0;
|
||||||
|
|
||||||
document.querySelectorAll('[name]').forEach(function(el) {
|
document.querySelectorAll('[name]').forEach(function(el) {
|
||||||
var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0, -2) : raw;
|
var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0, -2) : raw;
|
||||||
if (!(name in datos)) return;
|
if (!(name in datos)) return;
|
||||||
@@ -2314,9 +2321,25 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
|
|||||||
} else {
|
} else {
|
||||||
el.value = val;
|
el.value = val;
|
||||||
}
|
}
|
||||||
|
tocados.push(el);
|
||||||
|
llenos++;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Asignar el valor a mano no dispara ningún evento, y las secciones
|
||||||
|
// condicionales del formulario —tipo de dolor, tipo de cáncer, grupo
|
||||||
|
// sanguíneo— se despliegan escuchando 'change' e 'input'. Sin esto los
|
||||||
|
// datos entraban pero las secciones seguían ocultas, así que parecía
|
||||||
|
// que el botón no hacía nada.
|
||||||
|
tocados.forEach(function(el) {
|
||||||
|
['input', 'change'].forEach(function(tipo) {
|
||||||
|
el.dispatchEvent(new Event(tipo, { bubbles: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
msg.style.display = '';
|
msg.style.display = '';
|
||||||
msg.textContent = 'Datos cargados de la visita ' + (json.turno_codigo || '') + '. Revise y firme.';
|
msg.textContent = llenos
|
||||||
|
? 'Se cargaron ' + llenos + ' dato(s) de la visita ' + (json.turno_codigo || '') + '. Revise y firme.'
|
||||||
|
: 'La visita ' + (json.turno_codigo || '') + ' no tiene datos que correspondan a este formulario.';
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user