Compare commits
39
Commits
381c2ca04e
..
main
@@ -21,6 +21,53 @@ requireAuthentication();
|
||||
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
||||
$db = Database::getInstance();
|
||||
|
||||
/**
|
||||
* Le pide el teléfono a quien lo tiene oculto en WhatsApp, una sola vez.
|
||||
*
|
||||
* Solo aplica a quien se identifica con un BSUID: de esa persona no tenemos
|
||||
* número, y sin él el laboratorio no puede llamarla. Se le manda el botón que
|
||||
* Meta dispone para esto y ella decide si lo comparte; si acepta, el webhook
|
||||
* recibe el teléfono y lo vincula solo.
|
||||
*
|
||||
* No se insiste: pedir los datos una vez es razonable, repetirlo en cada
|
||||
* trámite es acoso. Tampoco se interrumpe la creación de la ficha si el envío
|
||||
* falla, porque la ficha es lo importante.
|
||||
*
|
||||
* @return string qué pasó, para que la interfaz lo pueda mostrar
|
||||
*/
|
||||
function pedirContactoSiHaceFalta(Database $db, int $userId): string {
|
||||
$u = $db->fetch(
|
||||
'SELECT phone_number, contacto_pedido_at FROM users WHERE id = ?',
|
||||
[$userId]
|
||||
);
|
||||
if (!$u) return 'usuario_no_encontrado';
|
||||
if (!esBsuid($u['phone_number'])) return 'no_hace_falta'; // ya tenemos su número
|
||||
if (!empty($u['contacto_pedido_at'])) return 'ya_se_pidio';
|
||||
|
||||
try {
|
||||
require_once __DIR__ . '/../../services/WhatsAppService.php';
|
||||
$wa = new WhatsAppService();
|
||||
$texto = getConfigFromDB(
|
||||
'whatsapp_texto_pedir_contacto',
|
||||
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?'
|
||||
);
|
||||
|
||||
$r = $wa->pedirContacto($u['phone_number'], $texto);
|
||||
if (!$r) {
|
||||
error_log('[crear_desde_whatsapp] WhatsApp rechazó la solicitud de contacto del usuario ' . $userId);
|
||||
return 'fallo_envio';
|
||||
}
|
||||
|
||||
// Se marca solo si Meta aceptó: si falló, hay que poder reintentarlo
|
||||
$db->update('users', ['contacto_pedido_at' => date('Y-m-d H:i:s')], 'id = ?', [$userId]);
|
||||
return 'pedido';
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('[crear_desde_whatsapp] Error pidiendo el contacto: ' . $e->getMessage());
|
||||
return 'fallo_envio';
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET: solo_paciente ─────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$pacienteRepo = new Paciente();
|
||||
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
||||
$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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
||||
foreach ($value['contacts'] as $contact) {
|
||||
$waId = $contact['wa_id'] ?? null;
|
||||
$name = $contact['profile']['name'] ?? null;
|
||||
if ($waId && $name) {
|
||||
$contactNames[$waId] = $name;
|
||||
if (!$name) continue;
|
||||
foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
|
||||
if ($clave) $contactNames[$clave] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($items as $message) {
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos.
|
||||
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
|
||||
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
|
||||
$timestamp = $message['timestamp'] ?? null;
|
||||
|
||||
// Meta manda el BSUID en todos los mensajes, traigan teléfono o no.
|
||||
$bsuid = $message['from_user_id'] ?? null;
|
||||
|
||||
// Si la persona oculta su teléfono, `from` y `wa_id` no llegan. En ese caso
|
||||
// se busca por BSUID: si ya escribió antes mostrando su número, se le
|
||||
// reconoce y se le sigue respondiendo a ese teléfono.
|
||||
if (empty($phoneNumber) && $bsuid) {
|
||||
$conocido = $this->db->fetch(
|
||||
"SELECT phone_number FROM users WHERE bsuid = :b", ['b' => $bsuid]
|
||||
);
|
||||
// Si no se le conoce, el BSUID hace de identificador: sirve para responderle,
|
||||
// aunque no permita cruzarlo con el paciente ni con el turnero.
|
||||
$phoneNumber = $conocido['phone_number'] ?? $bsuid;
|
||||
}
|
||||
|
||||
// Si falta lo crítico, saltar
|
||||
if (empty($phoneNumber) || empty($messageId)) {
|
||||
continue;
|
||||
@@ -148,7 +165,7 @@ class WhatsAppWebhook {
|
||||
|
||||
// Obtener o crear usuario
|
||||
$user = $this->getUserByPhone($phoneNumber);
|
||||
$contactName = $contactNames[$phoneNumber] ?? null;
|
||||
$contactName = $contactNames[$phoneNumber] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : null);
|
||||
if (!$user) {
|
||||
$userId = $this->createUser($phoneNumber);
|
||||
$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
|
||||
$messageText = '';
|
||||
$messageType = 'text';
|
||||
@@ -191,6 +222,28 @@ class WhatsAppWebhook {
|
||||
'reaction_emoji' => $emoji
|
||||
];
|
||||
|
||||
} elseif (($message['type'] ?? '') === 'contacts') {
|
||||
// La persona compartió su contacto, sea por el botón que se le pidió
|
||||
// o a mano. Es la única forma de obtener el teléfono de quien lo oculta.
|
||||
$messageType = 'contacts';
|
||||
$messageText = json_encode($message['contacts'] ?? []);
|
||||
|
||||
$telefonoCompartido = null;
|
||||
foreach ($message['contacts'] ?? [] as $c) {
|
||||
foreach ($c['phones'] ?? [] as $t) {
|
||||
// wa_id ya viene normalizado; `phone` puede traer espacios y signos
|
||||
$candidato = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||
if ($candidato) {
|
||||
$telefonoCompartido = preg_replace('/[^0-9]/', '', $candidato);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($telefonoCompartido && !empty($user['id'])) {
|
||||
$this->vincularTelefonoCompartido($user, $telefonoCompartido, $bsuid);
|
||||
}
|
||||
|
||||
} elseif (isset($message['interactive'])) {
|
||||
// Interactive replies (list or button) - normalize to text so bot can process
|
||||
$messageType = 'text';
|
||||
@@ -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) {
|
||||
return $this->db->fetch(
|
||||
"SELECT * FROM users WHERE phone_number = :phone",
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
// esBsuid() vive en config.php; se garantiza aquí por si esta clase se incluye
|
||||
// directamente, sin pasar por el arranque del ERP.
|
||||
if (!function_exists('esBsuid')) {
|
||||
$configPaciente = __DIR__ . '/../../config/config.php';
|
||||
if (file_exists($configPaciente)) {
|
||||
require_once $configPaciente;
|
||||
}
|
||||
}
|
||||
|
||||
class Paciente {
|
||||
|
||||
private Database $db;
|
||||
@@ -258,10 +267,19 @@ class Paciente {
|
||||
[$userId]
|
||||
);
|
||||
|
||||
// Quien oculta su teléfono en WhatsApp se identifica con un BSUID, que ocupa
|
||||
// el lugar del número en `users`. No es un teléfono: guardarlo aquí dejaría
|
||||
// en la historia clínica un dato falso con apariencia de número real, porque
|
||||
// normalizarTelefono() le quita el punto y las letras y lo deja en 16 dígitos.
|
||||
// Mejor la ficha sin teléfono, que es la verdad: no lo tenemos.
|
||||
$identificador = $user['phone_number'] ?? null;
|
||||
$esIdentificadorSinTelefono = esBsuid($identificador);
|
||||
|
||||
return $this->crear([
|
||||
'user_id' => $userId,
|
||||
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
|
||||
'telefono' => $user['phone_number'] ?? null,
|
||||
'nombre_completo'=> $user['name']
|
||||
?? ($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
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,14 @@ class Router
|
||||
private const PUBLIC_ROUTES = [
|
||||
'turnero/display',
|
||||
'turnero/kiosko',
|
||||
// Tablet de firma del paciente: la manipula el público y nadie va a
|
||||
// iniciar sesión en ella cada mañana. No queda abierta: se identifica
|
||||
// por la cookie del dispositivo y sin ella no muestra dato alguno.
|
||||
'turnero/firma',
|
||||
// Prueba de voces: hay que abrirla EN el televisor para saber qué voces
|
||||
// tiene ese equipo, y allí no hay sesión iniciada. No expone nada: solo
|
||||
// lista las voces del navegador y lee una frase de ejemplo inventada.
|
||||
'turnero/voces',
|
||||
];
|
||||
|
||||
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
|
||||
|
||||
@@ -70,6 +70,8 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
<option value="enfermeras">Enfermeras</option>
|
||||
<option value="formularios">Formularios</option>
|
||||
<option value="asignaciones">Asignaciones</option>
|
||||
<option value="turnero">Turnero</option>
|
||||
<option value="turnero_config">Turnero · configuración</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
@@ -85,6 +87,11 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
<option value="enviar">enviar</option>
|
||||
<option value="reprogramar">reprogramar</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>
|
||||
</div>
|
||||
<div class="col">
|
||||
@@ -104,6 +111,18 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
||||
</div>
|
||||
|
||||
<!-- 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-body p-0">
|
||||
<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();
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
const params = new URLSearchParams({
|
||||
desde: document.getElementById('act-desde').value,
|
||||
|
||||
+35
-3
@@ -472,6 +472,28 @@ function fmtFecha(str) {
|
||||
return str.slice(0, 10).split('-').reverse().join('/');
|
||||
}
|
||||
|
||||
// Quien oculta su número en WhatsApp llega identificado con un BSUID
|
||||
// ("CO.1761088155094242"), que se guarda en phone_number pero no es un teléfono.
|
||||
const esBsuid = v => /^[A-Z]{2}\.\d+$/.test(String(v || ''));
|
||||
|
||||
/**
|
||||
* Celda de contacto. Si de esta persona no tenemos teléfono porque lo tiene
|
||||
* oculto, se dice así en vez de mostrar el identificador crudo: recepción
|
||||
* necesita entender por qué no puede llamarla, no ver un código.
|
||||
*/
|
||||
function celdaTelefono(p) {
|
||||
if (esBsuid(p.phone_number)) {
|
||||
return (p.telefono ? esc(p.telefono) : '<span class="text-muted">Sin teléfono</span>')
|
||||
+ '<br><i class="fab fa-whatsapp text-success"></i> '
|
||||
+ '<small class="text-muted" title="Tiene el número oculto en WhatsApp. '
|
||||
+ 'Se le puede escribir por el chat, pero no llamar.">Solo por WhatsApp</small>';
|
||||
}
|
||||
return esc(p.telefono || '—')
|
||||
+ (p.phone_number
|
||||
? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>`
|
||||
: '');
|
||||
}
|
||||
|
||||
async function cargarLista(pag = 1) {
|
||||
paginaActual = pag;
|
||||
const busq = document.getElementById('buscador').value.trim();
|
||||
@@ -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>` : ''}
|
||||
</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.ciudad||'—')}</td>
|
||||
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
||||
@@ -546,8 +568,18 @@ async function verDetalle(id) {
|
||||
document.getElementById('detail-body').innerHTML = `
|
||||
<dl class="row small mb-3">
|
||||
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${
|
||||
p.telefono ? esc(p.telefono)
|
||||
: (esBsuid(p.phone_number)
|
||||
? '<span class="text-muted">Sin teléfono — lo tiene oculto en WhatsApp</span>'
|
||||
: '—')
|
||||
}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${
|
||||
!p.phone_number ? '—'
|
||||
: (esBsuid(p.phone_number)
|
||||
? '<i class="fab fa-whatsapp text-success"></i> Se le puede escribir por el chat, pero no llamar'
|
||||
: `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}`)
|
||||
}</dd>
|
||||
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
|
||||
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
|
||||
${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__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -233,6 +234,27 @@ try {
|
||||
|
||||
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}'");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Body: { consentimiento_id }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -30,4 +31,35 @@ $stmt->execute([$turnoId, $consentId]);
|
||||
|
||||
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]);
|
||||
|
||||
@@ -19,9 +19,8 @@ $svg = $body['svg'] ?? '';
|
||||
$soloPro = !empty($body['solo_profesional']);
|
||||
// Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
|
||||
$datosResp = null;
|
||||
if (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']) && !empty($body['datos_respuestas'])) {
|
||||
$datosResp = json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$datosArr = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
|
||||
? $body['datos_respuestas'] : [];
|
||||
|
||||
if (!$turnoId) jsonError('turno_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();
|
||||
|
||||
// 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(
|
||||
"SELECT tc.id, tc.estado, t.sesion_id
|
||||
FROM turnero_consentimientos tc
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
|
||||
* - desglose por prioridad
|
||||
* - 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)
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
@@ -41,10 +42,12 @@ if (!$sesion) {
|
||||
'tiempo_espera_promedio_min' => null,
|
||||
'tiempo_servicio_promedio_min' => null,
|
||||
],
|
||||
'por_prioridad' => [],
|
||||
'por_lugar' => [],
|
||||
'consent_stats' => [],
|
||||
'turnos' => [],
|
||||
'por_prioridad' => [],
|
||||
'por_lugar' => [],
|
||||
'por_recepcionista' => [],
|
||||
'por_bacteriologo' => [],
|
||||
'consent_stats' => [],
|
||||
'turnos' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -54,36 +57,51 @@ $sesionId = (int)$sesion['id'];
|
||||
$stmtRes = $pdo->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(estado IN ('finalizado','en_servicio')) AS atendidos,
|
||||
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
||||
SUM(estado = 'ausente') AS ausentes,
|
||||
SUM(estado = 'cancelado') AS cancelados,
|
||||
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
|
||||
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
||||
SUM(t.estado = 'ausente') AS ausentes,
|
||||
SUM(t.estado = 'cancelado') AS cancelados,
|
||||
ROUND(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0
|
||||
WHEN t.inicio_recepcion_at IS NOT NULL AND t.creado_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, t.creado_at, t.inicio_recepcion_at) / 60.0
|
||||
END
|
||||
), 1
|
||||
) 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(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
|
||||
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_servicio_promedio_min,
|
||||
ROUND(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60.0
|
||||
WHEN t.creado_at IS NOT NULL
|
||||
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
|
||||
), 1
|
||||
) AS tiempo_total_promedio_min
|
||||
FROM turnero_turnos
|
||||
WHERE sesion_id = ?"
|
||||
FROM turnero_turnos t
|
||||
-- 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]);
|
||||
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
|
||||
@@ -112,12 +130,20 @@ $stmtLug = $pdo->prepare(
|
||||
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
|
||||
ROUND(AVG(
|
||||
CASE
|
||||
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0
|
||||
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_servicio_promedio_min
|
||||
FROM turnero_turnos t
|
||||
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 = ?
|
||||
GROUP BY l.id
|
||||
ORDER BY l.sort_order ASC, l.nombre ASC"
|
||||
@@ -125,6 +151,63 @@ $stmtLug = $pdo->prepare(
|
||||
$stmtLug->execute([$sesionId]);
|
||||
$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 ────────────────────────────────
|
||||
$stmtTurnos = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.numero, t.estado,
|
||||
@@ -157,22 +240,47 @@ $stmtTurnos = $pdo->prepare(
|
||||
$stmtTurnos->execute([$sesionId]);
|
||||
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Enriquecer con exámenes y comentarios
|
||||
foreach ($turnos as &$turno) {
|
||||
// Enriquecer con exámenes y comentarios — en lote.
|
||||
// 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(
|
||||
"SELECT DISTINCT et.nombre FROM turnero_examen_items tei
|
||||
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||
WHERE tei.solicitud_id = (SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1)"
|
||||
"SELECT DISTINCT ts.turno_id, et.nombre
|
||||
FROM turnero_solicitudes ts
|
||||
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']]);
|
||||
$turno['examenes'] = $stmtEx->fetchAll(PDO::FETCH_ASSOC);
|
||||
$stmtEx->execute($turnoIds);
|
||||
foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$examenesPorTurno[(int)$row['turno_id']][] = ['nombre' => $row['nombre']];
|
||||
}
|
||||
|
||||
$stmtCom = $pdo->prepare(
|
||||
"SELECT usuario_nombre, comentario, tipo, creado_at
|
||||
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at ASC"
|
||||
"SELECT turno_id, usuario_nombre, comentario, tipo, creado_at
|
||||
FROM turnero_comentarios
|
||||
WHERE turno_id IN ($ph)
|
||||
ORDER BY turno_id, creado_at ASC"
|
||||
);
|
||||
$stmtCom->execute([$turno['id']]);
|
||||
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
|
||||
$stmtCom->execute($turnoIds);
|
||||
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);
|
||||
|
||||
@@ -201,6 +309,8 @@ jsonOk([
|
||||
'resumen' => $resumen,
|
||||
'por_prioridad' => $porPrioridad,
|
||||
'por_lugar' => $porLugar,
|
||||
'por_recepcionista' => $porRecepcionista,
|
||||
'por_bacteriologo' => $porBacteriologo,
|
||||
'consent_stats' => $consentStats,
|
||||
'turnos' => $turnos,
|
||||
]);
|
||||
|
||||
@@ -76,6 +76,8 @@ if (empty($escritoriosRec)) {
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.sesion_id = ?
|
||||
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"
|
||||
);
|
||||
$stmtRec->execute([$sesionId]);
|
||||
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = 'en_servicio'
|
||||
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
|
||||
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.
|
||||
*
|
||||
* 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)
|
||||
* estado string ("finalizado","ausente","cancelado","en_servicio",... o vacío = todos)
|
||||
* lugar_id int (0 = todos)
|
||||
@@ -23,16 +23,29 @@ $pdo = db();
|
||||
// ── Parámetros ────────────────────────────────────────────────
|
||||
$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'] ?? '');
|
||||
$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 ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
|
||||
|
||||
$estadoFiltro = trim($_GET['estado'] ?? '');
|
||||
$lugarId = (int)($_GET['lugar_id'] ?? 0);
|
||||
$prioridadId = (int)($_GET['prioridad_id'] ?? 0);
|
||||
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
|
||||
$q = trim($_GET['q'] ?? '');
|
||||
|
||||
$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__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -144,6 +145,20 @@ try {
|
||||
$pdo->commit();
|
||||
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");
|
||||
|
||||
} 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__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -48,4 +49,15 @@ $stmtT = $pdo->prepare(
|
||||
$stmtT->execute([$turnoId]);
|
||||
$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]);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Body JSON: { token: string }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -14,9 +15,11 @@ if (!$token) jsonError('token requerido.');
|
||||
|
||||
$pdo = db();
|
||||
$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
|
||||
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"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
@@ -34,4 +37,25 @@ $pdo->prepare(
|
||||
)->execute([$token]);
|
||||
|
||||
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.');
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Body JSON: { token: string, campos: string[] }
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -17,9 +18,11 @@ if (!is_array($campos) || empty($campos)) jsonError('campos requerido.');
|
||||
|
||||
$pdo = db();
|
||||
$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
|
||||
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"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
@@ -40,4 +43,24 @@ $pdo->prepare(
|
||||
)->execute([json_encode($dr, JSON_UNESCAPED_UNICODE), $token]);
|
||||
|
||||
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.');
|
||||
|
||||
@@ -32,6 +32,13 @@ header('Cache-Control: no-store, no-cache');
|
||||
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
|
||||
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 ────────────────────────────────────────────────
|
||||
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
|
||||
$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.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||
requireMethod('POST');
|
||||
requireTurnero();
|
||||
|
||||
@@ -27,4 +28,22 @@ $stmt->execute([$pacienteId, $nombreCompleto, $turnoId]);
|
||||
|
||||
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]);
|
||||
|
||||
@@ -251,6 +251,9 @@ Layout::open('Bandeja del día', 'fas fa-layer-group');
|
||||
.bnd-com-user { font-weight: 600; color: #1e293b; }
|
||||
.bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; }
|
||||
.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 */
|
||||
.bnd-add-com { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.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>
|
||||
</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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -578,6 +578,10 @@ let state = {
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
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(() => {
|
||||
loadContacts(document.getElementById('searchInput').value.trim());
|
||||
}, 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'] ?>"
|
||||
style="border:1px solid #e2e8f0;border-radius:10px">
|
||||
<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'): ?>
|
||||
<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: ?>
|
||||
<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; ?>
|
||||
<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>
|
||||
|
||||
@@ -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-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-ayuda { font-size:.62rem; color:#94a3b8; line-height:1.3; margin-top:5px;
|
||||
border-top:1px solid #f1f5f9; padding-top:5px; }
|
||||
|
||||
/* ── Charts / tables section ── */
|
||||
.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>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="section-card">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||
@@ -445,7 +459,8 @@ function mostrarError(msg) {
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
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 ────────────────────────────────────────
|
||||
const si = document.getElementById('sesionInfo');
|
||||
@@ -471,14 +486,22 @@ function renderDashboard(json) {
|
||||
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
|
||||
|
||||
document.getElementById('kpiGrid').innerHTML = `
|
||||
${kpiCard(total, '📋 Total turnos', '')}
|
||||
${kpiCard(atendidos,'✅ Atendidos', pct + '%')}
|
||||
${kpiCard(enEspera, '⏳ En espera', '')}
|
||||
${kpiCard(ausentes, '🚫 Ausentes', '')}
|
||||
${kpiCard(cancelados,'❌ Cancelados', '')}
|
||||
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')}
|
||||
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')}
|
||||
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total prom.', 'puerta a puerta')}
|
||||
${kpiCard(total, '📋 Turnos del día', '',
|
||||
'Todos los turnos generados hoy, sin importar cómo terminaron.')}
|
||||
${kpiCard(atendidos, '✅ Atendidos', pct + ' % del día',
|
||||
'Ya terminaron su atención, más los que están siendo atendidos en este momento.')}
|
||||
${kpiCard(enEspera, '⏳ Esperando', '',
|
||||
'Todavía no los llaman, o ya salieron de recepción y esperan el puesto de toma.')}
|
||||
${kpiCard(ausentes, '🚫 No se presentaron', '',
|
||||
'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 ────────────────────────────────────
|
||||
@@ -536,16 +559,51 @@ function renderDashboard(json) {
|
||||
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 ────────────────────────────────────
|
||||
_turnos = turnos;
|
||||
renderTabla(turnos);
|
||||
}
|
||||
|
||||
function kpiCard(val, lbl, sub) {
|
||||
return `<div class="kpi-card">
|
||||
// Tarjetas "quién atendió a cuántos". Reutiliza el grid de lugares para que
|
||||
// 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-lbl">${lbl}</div>
|
||||
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
|
||||
${ayuda ? `<div class="kpi-ayuda">${escHtml(ayuda)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -271,9 +271,13 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
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="">
|
||||
</video>
|
||||
</div>
|
||||
@@ -338,7 +342,7 @@ document.getElementById('lbl-area').textContent =
|
||||
const body = document.getElementById('display-body');
|
||||
src.src = videoUrl;
|
||||
vid.load();
|
||||
panel.style.display = 'block';
|
||||
panel.style.display = 'flex'; // flex, para poder centrar el cuadro
|
||||
// 3 columnas: turno | video | cola
|
||||
body.style.gridTemplateColumns = '1fr 320px 280px';
|
||||
})();
|
||||
|
||||
@@ -112,8 +112,12 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
grid-template-columns: 1fr 29%;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* ── 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 {
|
||||
display: none;
|
||||
height: 100%;
|
||||
max-width: 50vw;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
width: 100%; height: 100%;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-width: 100%;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: -8px 0 30px rgba(0,0,0,.08);
|
||||
@@ -378,7 +395,7 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
<div class="dot"></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
|
||||
</div>
|
||||
|
||||
@@ -404,7 +421,7 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
<div class="hd-right">
|
||||
<div class="live-dot"></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>
|
||||
</button>
|
||||
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
|
||||
@@ -499,14 +516,22 @@ function tick() {
|
||||
tick(); setInterval(tick, 1000);
|
||||
|
||||
/* ── 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 {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
} else if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume();
|
||||
// resume() es asíncrono: sin esto el letrero parpadeaba al abrir,
|
||||
// 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 o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
|
||||
@@ -516,13 +541,39 @@ function activarSonido() {
|
||||
localStorage.setItem('turneroSonido', '1');
|
||||
const btn = document.getElementById('btn-sonido');
|
||||
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
|
||||
const banner = document.getElementById('sonido-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
setTimeout(playBeep, 100);
|
||||
// El pito de confirmación solo cuando alguien tocó: al arrancar sola, la
|
||||
// pantalla no tiene por qué pitar cada vez que se recarga.
|
||||
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() {
|
||||
if (!sonidoActivo || !audioCtx) return;
|
||||
@@ -544,60 +595,247 @@ function playBeep() {
|
||||
|
||||
// Selección de voz española — cargada una vez, reutilizada en cada anuncio
|
||||
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() {
|
||||
if (_vozES) return _vozES;
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
// Preferencia: es-CO → es-419 → es-MX → es-US → es-ES → cualquier es-*
|
||||
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
|
||||
const v = voices.find(v => v.lang === lang);
|
||||
if (v) { _vozES = v; return v; }
|
||||
const voces = window.speechSynthesis.getVoices().filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
|
||||
if (!voces.length) return null;
|
||||
|
||||
const locales = voces.filter(v => v.localService);
|
||||
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'));
|
||||
if (v) { _vozES = v; }
|
||||
return _vozES;
|
||||
// 2. Cualquier otra local latinoamericana
|
||||
for (const lang of ['es-MX', 'es-419', 'es-US', 'es-CO']) {
|
||||
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) {
|
||||
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
|
||||
// Keepalive: Chrome suspende speechSynthesis tras un rato sin uso.
|
||||
// Solo debe correr con el sintetizador en reposo: pause() a mitad de una
|
||||
// frase la corta, y como el anuncio dura más de 10 segundos, antes lo
|
||||
// troceaba siempre.
|
||||
// Keepalive con TRABAJO MUDO. El motor de voz (comprobado en macOS, y
|
||||
// reportado también en Windows) se duerme tras un rato de ocio, y el primer
|
||||
// speak() de ahí en adelante se ATASCA: speaking=true, cero audio, ni
|
||||
// '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(() => {
|
||||
if (!sonidoActivo) 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();
|
||||
}, 10000);
|
||||
window.speechSynthesis.speak(u);
|
||||
}, 25000);
|
||||
}
|
||||
// Auto-restore sonido si estaba activo antes de un reload
|
||||
if (localStorage.getItem('turneroSonido') === '1') {
|
||||
const banner = document.getElementById('sonido-banner');
|
||||
if (banner) banner.style.display = 'flex';
|
||||
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
|
||||
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
|
||||
|
||||
// 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();
|
||||
if (!sonidoActivo || !('speechSynthesis' in window)) return null;
|
||||
if (!sonidoActivo || !('speechSynthesis' in window)) return false;
|
||||
|
||||
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}`;
|
||||
if (nomVoz) texto += `, ${nomVoz}`;
|
||||
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
|
||||
// curso hace falta cancelar y esperar un instante: Chrome ignora un speak()
|
||||
// encadenado a un cancel() en el mismo ciclo.
|
||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
|
||||
window.speechSynthesis.cancel();
|
||||
setTimeout(() => window.speechSynthesis.speak(utt), 80);
|
||||
} else {
|
||||
window.speechSynthesis.speak(utt);
|
||||
}
|
||||
return utt;
|
||||
let avisado = false;
|
||||
const avisar = () => { if (!avisado) { avisado = true; onFin(); } };
|
||||
|
||||
const decir = (esReintento) => {
|
||||
const utt = new SpeechSynthesisUtterance(texto);
|
||||
utt.lang = 'es-CO';
|
||||
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
|
||||
|
||||
_uttAncladas.add(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 ── */
|
||||
@@ -642,8 +880,6 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
|
||||
|
||||
ov.classList.add('visible');
|
||||
|
||||
const utt = anunciarTurno(codigo, destino, paciente);
|
||||
|
||||
bar.style.transition = 'none';
|
||||
bar.style.width = '100%';
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
@@ -668,11 +904,15 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
|
||||
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
|
||||
};
|
||||
|
||||
if (utt) {
|
||||
utt.addEventListener('end', () => setTimeout(cerrarCuandoToque, 400));
|
||||
utt.addEventListener('error', cerrarCuandoToque);
|
||||
// Red de seguridad: Chrome a veces no emite 'end' y el cartel quedaría fijo
|
||||
setTimeout(dismiss, 25000);
|
||||
// anunciarTurno avisa tanto si terminó de hablar como si quedó claro que no
|
||||
// va a hablar. Esperar solo el fin de la voz dejaba el cartel pegado en las
|
||||
// pantallas donde el navegador bloquea el audio.
|
||||
const intentoHablar = anunciarTurno(codigo, destino, paciente,
|
||||
() => setTimeout(cerrarCuandoToque, 400));
|
||||
|
||||
if (intentoHablar) {
|
||||
// Red de seguridad: Chrome a veces no emite 'end' a mitad de una frase
|
||||
setTimeout(dismiss, 20000);
|
||||
} else {
|
||||
setTimeout(dismiss, MIN_VISIBLE);
|
||||
}
|
||||
@@ -727,18 +967,16 @@ function renderSnapshot(snap) {
|
||||
});
|
||||
lastSeenKeys = currentKeys;
|
||||
|
||||
if (_firstLoad) {
|
||||
_firstLoad = false;
|
||||
const sorted = allTurnos.slice().sort((a, b) => new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0));
|
||||
if (sorted.length > 0) {
|
||||
const t = sorted[0];
|
||||
const c = t.prioridad_color || '<?= $_labColor ?>';
|
||||
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
|
||||
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
|
||||
announcedCalls.add(key);
|
||||
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
|
||||
}
|
||||
}
|
||||
// Al abrir la pantalla NO se anuncia nada por voz. Antes se llamaba al turno
|
||||
// que estuviera arriba, pero eso no es un llamado nuevo: es el estado en que
|
||||
// se encontró el puesto. Si allí había un turno sin cerrar de horas atrás, el
|
||||
// televisor gritaba el nombre de un paciente que ya se había ido. Y como esta
|
||||
// pantalla se recarga sola, cada recarga era una oportunidad de equivocarse.
|
||||
//
|
||||
// El recorrido de arriba ya dio por vistos los turnos presentes —registra la
|
||||
// clave sin anunciar mientras _firstLoad siga en pie—, así que aquí solo hay
|
||||
// que levantar la bandera: de la siguiente vuelta en adelante sí se anuncia.
|
||||
if (_firstLoad) _firstLoad = false;
|
||||
|
||||
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
|
||||
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(); });
|
||||
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 ── */
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -472,11 +480,19 @@ function renderTabla(turnos) {
|
||||
const bacterio = esc(t.atendido_lugar_nombre || '—');
|
||||
|
||||
// Exámenes: badges con código
|
||||
const examsHtml = (t.examenes && t.examenes.length)
|
||||
? 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('')
|
||||
// Solo el conteo: con 9,5 exámenes de promedio por turno, las etiquetas de
|
||||
// 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>';
|
||||
|
||||
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><strong style="color:${priCls}">${esc(t.codigo)}</strong></td>
|
||||
<td>
|
||||
@@ -496,19 +512,19 @@ function renderTabla(turnos) {
|
||||
<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>
|
||||
</td>
|
||||
<td style="max-width:140px">${examsHtml}</td>
|
||||
<td class="text-nowrap">${examsHtml}</td>
|
||||
<td><span class="eb ${cls}">${lbl}</span></td>
|
||||
<td class="text-nowrap">${espMin}</td>
|
||||
<td class="text-nowrap">${srvMin}</td>
|
||||
<td class="text-nowrap">${totMin}</td>
|
||||
<td class="text-nowrap">
|
||||
<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">
|
||||
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
|
||||
</button>
|
||||
<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">
|
||||
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
|
||||
</button>
|
||||
@@ -594,12 +610,32 @@ function renderDetalle(t) {
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
${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-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>
|
||||
</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) {
|
||||
const row = document.getElementById(rowId);
|
||||
const icon = btn.querySelector('i');
|
||||
@@ -614,11 +650,79 @@ function toggleDetalle(rowId, btn, turnoId) {
|
||||
docsEl.dataset.loaded = '1';
|
||||
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 ?>';
|
||||
|
||||
// ── 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) {
|
||||
try {
|
||||
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>
|
||||
<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 -->
|
||||
<div id="mpac-identidad-lock" class="mpac-lock">
|
||||
<div class="mpac-lock-txt">
|
||||
@@ -1355,9 +1363,11 @@ async function seleccionarSinLlamar(turnoId) {
|
||||
mostrarCabeceraTurno(t);
|
||||
await cargarFichaSolicitud(t.id);
|
||||
clearInterval(pollingConsentId);
|
||||
if (!_esSoloEntrega(t))
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(t))
|
||||
// También para solo entrega de muestras: a esos turnos les corresponde el
|
||||
// formulario del puesto, así que la lista tiene que refrescarse igual o al
|
||||
// firmar no se vería el cambio.
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
|
||||
cargarFormEmbebido(t.id);
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
@@ -1400,9 +1410,8 @@ async function abrirFicha(turno) {
|
||||
mostrarCabeceraTurno(turno);
|
||||
await cargarFichaSolicitud(turno.id);
|
||||
clearInterval(pollingConsentId);
|
||||
if (!_esSoloEntrega(turno))
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(turno))
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
|
||||
cargarFormEmbebido(turno.id);
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
@@ -1468,10 +1477,15 @@ async function cargarFichaSolicitud(turnoId) {
|
||||
const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
|
||||
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 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');
|
||||
|
||||
// Embarazada
|
||||
@@ -2230,7 +2244,7 @@ function resetFicha() {
|
||||
_muestrasActivas = [];
|
||||
const secMuestras = document.getElementById('sec-muestras');
|
||||
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('badge-solo-muestras')?.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-dir').value = p.direccion || '';
|
||||
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-tipodoc').value = p.tipo_documento || 'CC';
|
||||
document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || '';
|
||||
@@ -2551,6 +2566,7 @@ async function mpacGuardarFicha() {
|
||||
telefono: val('mpac-in-tel'),
|
||||
direccion: val('mpac-in-dir'),
|
||||
eps: val('mpac-in-eps'),
|
||||
genero: val('mpac-in-genero'),
|
||||
};
|
||||
// Los datos de identidad solo se envían si se desbloquearon
|
||||
if (!document.getElementById('mpac-identidad').classList.contains('d-none')) {
|
||||
|
||||
@@ -1553,7 +1553,7 @@ function abrirFicha(turno) {
|
||||
.catch(() => {});
|
||||
} 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
|
||||
consultarExamenesRips(turno.paciente_nombre.trim());
|
||||
iniciarSondeoRips(turno.paciente_nombre.trim());
|
||||
}
|
||||
|
||||
// Cargar consentimientos del desk actual para este turno
|
||||
@@ -1673,7 +1673,7 @@ function seleccionarPaciente(pac) {
|
||||
|
||||
// Consultar exámenes recientes en RIPS (últimos 5 min)
|
||||
const cedula = (pac.numero_documento || pac.documento || '').toString().trim();
|
||||
if (cedula) consultarExamenesRips(cedula);
|
||||
if (cedula) iniciarSondeoRips(cedula);
|
||||
filtrarExamenesPorGenero();
|
||||
}
|
||||
|
||||
@@ -1696,6 +1696,31 @@ function filtrarExamenesPorGenero() {
|
||||
// ── Exámenes desde RIPS ───────────────────────────────────────
|
||||
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) {
|
||||
_ripsData = null;
|
||||
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 d = await r.json();
|
||||
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;
|
||||
const hora = d.hora ? ' (' + String(d.hora).slice(0, 5) + ')' : '';
|
||||
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
|
||||
// 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();
|
||||
} else {
|
||||
document.getElementById('banner-rips-txt').textContent =
|
||||
@@ -1760,6 +1796,7 @@ async function cargarExamenesRips() {
|
||||
}
|
||||
|
||||
function descartarRips() {
|
||||
detenerSondeoRips();
|
||||
_ripsData = null;
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
$interactive = [
|
||||
@@ -767,6 +793,14 @@ class WhatsAppService
|
||||
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)
|
||||
error_log("WhatsApp API URL: " . $url);
|
||||
error_log("WhatsApp Token length: " . strlen($this->token));
|
||||
@@ -881,11 +915,27 @@ class WhatsAppService
|
||||
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
|
||||
*/
|
||||
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
|
||||
$phone = preg_replace('/[^0-9]/', '', $phone);
|
||||
|
||||
|
||||
@@ -696,9 +696,14 @@ function _addExamWizardHtml(string $cid): string {
|
||||
.campo-linked { display: flex; gap: 16px; padding: 6px 0;
|
||||
border-bottom: 1px solid #f0f0f0; }
|
||||
.campo-linked-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; }
|
||||
.lnk-corregir { background:none; border:none; color:#94a3b8; cursor:pointer;
|
||||
padding:0 4px; font-size:11px; flex-shrink:0; }
|
||||
.lnk-corregir:hover { color:#1565c0; }
|
||||
/* Antes era solo un lápiz gris de 11px: estaba, pero nadie lo encontraba.
|
||||
Lleva texto y contorno para que se lea como lo que es, un botón. */
|
||||
.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;
|
||||
color: #1565c0; }
|
||||
|
||||
@@ -1289,8 +1294,8 @@ function _addExamWizardHtml(string $cid): string {
|
||||
<div class="campo-linked-valor"><?= esc2($lval) ?></div>
|
||||
<?php if ($modoTurnero && $embebido && $modoEditar && !$_lnkBtnPuesto): $_lnkBtnPuesto = true; ?>
|
||||
<button type="button" class="lnk-corregir no-print" onclick="pedirCorregirPaciente()"
|
||||
title="Corregir los datos del paciente">
|
||||
<i class="fas fa-pen"></i>
|
||||
title="Abrir la ficha del paciente para corregir sus datos">
|
||||
<i class="fas fa-pen" style="font-size:9px"></i>Corregir
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -2302,6 +2307,8 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
|
||||
return;
|
||||
}
|
||||
var datos = json.datos_respuestas || {};
|
||||
var tocados = [], llenos = 0;
|
||||
|
||||
document.querySelectorAll('[name]').forEach(function(el) {
|
||||
var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0, -2) : raw;
|
||||
if (!(name in datos)) return;
|
||||
@@ -2314,9 +2321,25 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
|
||||
} else {
|
||||
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.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() {
|
||||
btn.disabled = false;
|
||||
|
||||
Reference in New Issue
Block a user