Compare commits
21
Commits
ccaf6f22cd
...
main
@@ -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,
|
||||||
|
]);
|
||||||
@@ -70,6 +70,8 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
<option value="enfermeras">Enfermeras</option>
|
<option value="enfermeras">Enfermeras</option>
|
||||||
<option value="formularios">Formularios</option>
|
<option value="formularios">Formularios</option>
|
||||||
<option value="asignaciones">Asignaciones</option>
|
<option value="asignaciones">Asignaciones</option>
|
||||||
|
<option value="turnero">Turnero</option>
|
||||||
|
<option value="turnero_config">Turnero · configuración</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
@@ -85,6 +87,11 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
<option value="enviar">enviar</option>
|
<option value="enviar">enviar</option>
|
||||||
<option value="reprogramar">reprogramar</option>
|
<option value="reprogramar">reprogramar</option>
|
||||||
<option value="cambiar_estado">cambiar_estado</option>
|
<option value="cambiar_estado">cambiar_estado</option>
|
||||||
|
<option value="quitar_consentimiento">quitar_consentimiento</option>
|
||||||
|
<option value="resetear_consentimiento">resetear_consentimiento</option>
|
||||||
|
<option value="resetear_toma">resetear_toma</option>
|
||||||
|
<option value="cancelar_toma_pendiente">cancelar_toma_pendiente</option>
|
||||||
|
<option value="vincular_paciente">vincular_paciente</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -104,6 +111,18 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabla -->
|
<!-- Tabla -->
|
||||||
|
<!-- Salud de la voz del televisor: alimentada por turnero_tv_log.
|
||||||
|
Responde de un vistazo «¿la pantalla está llamando bien?» sin
|
||||||
|
tener que consultar la base a mano. -->
|
||||||
|
<div class="card border-0 shadow-sm mb-3" id="card-voz-tv" style="display:none">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<h6 class="mb-2"><i class="fas fa-volume-up text-primary me-1"></i>
|
||||||
|
Voz del televisor — últimos 7 días</h6>
|
||||||
|
<div class="d-flex gap-4 flex-wrap mb-2" id="voz-tv-resumen"></div>
|
||||||
|
<div id="voz-tv-incidentes" class="small"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div id="act-spinner" class="text-center py-5 text-muted">
|
<div id="act-spinner" class="text-center py-5 text-muted">
|
||||||
@@ -270,6 +289,53 @@ function verDetalle(i) {
|
|||||||
new bootstrap.Modal(document.getElementById('modalDetalle')).show();
|
new bootstrap.Modal(document.getElementById('modalDetalle')).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Salud de la voz del televisor ────────────────────────────
|
||||||
|
const VOZ_EVENTO_TXT = {
|
||||||
|
mudo: ['🔇 No sonó', 'text-danger', 'El llamado salió sin voz: ni el reintento habló'],
|
||||||
|
sin_start: ['⚠️ Arrancó con reintento','text-warning','La voz elegida no arrancó; habló la del navegador'],
|
||||||
|
error: ['⚠️ Error del motor', 'text-warning', 'El motor de voz falló a mitad; se reintentó'],
|
||||||
|
lag: ['⏸ Pantalla congelada', 'text-warning', 'La pestaña estuvo oculta o el equipo suspendido: en ese lapso no ve llamados. En el televisor debe estar siempre visible'],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cargarVozTV() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('api/lab/get_tv_log.php');
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.ok) return;
|
||||||
|
// Sin datos todavía (recién desplegado): la tarjeta no aparece
|
||||||
|
if (!d.anuncios_7d && !d.incidentes.length) return;
|
||||||
|
|
||||||
|
document.getElementById('card-voz-tv').style.display = '';
|
||||||
|
const pct = d.anuncios_7d ? Math.round(d.completos_7d / d.anuncios_7d * 100) : 0;
|
||||||
|
document.getElementById('voz-tv-resumen').innerHTML = `
|
||||||
|
<div><span class="fw-bold fs-5">${d.anuncios_7d}</span>
|
||||||
|
<span class="text-muted small">llamados con voz</span></div>
|
||||||
|
<div><span class="fw-bold fs-5 ${pct >= 97 ? 'text-success' : 'text-warning'}">${pct}%</span>
|
||||||
|
<span class="text-muted small">hablados completos</span></div>
|
||||||
|
<div><span class="fw-bold fs-5 ${d.mudos_7d ? 'text-danger' : 'text-success'}">${d.mudos_7d}</span>
|
||||||
|
<span class="text-muted small">salieron mudos</span></div>
|
||||||
|
<div><span class="fw-bold fs-5 ${d.avisos_7d ? 'text-warning' : 'text-success'}">${d.avisos_7d}</span>
|
||||||
|
<span class="text-muted small">se recuperaron con reintento</span></div>`;
|
||||||
|
|
||||||
|
const inc = document.getElementById('voz-tv-incidentes');
|
||||||
|
if (!d.incidentes.length) {
|
||||||
|
inc.innerHTML = '<span class="text-success"><i class="fas fa-check-circle me-1"></i>Sin fallos registrados</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inc.innerHTML = '<div class="text-muted mb-1">Últimos fallos:</div>' +
|
||||||
|
d.incidentes.slice(0, 10).map(i => {
|
||||||
|
const [txt, cls, ayuda] = VOZ_EVENTO_TXT[i.evento] || [i.evento, '', ''];
|
||||||
|
return `<div class="py-1 border-bottom" title="${ayuda}">
|
||||||
|
<span class="text-muted">${(i.cuando || '').slice(0, 16)}</span>
|
||||||
|
· turno <b>${i.turno || '—'}</b>
|
||||||
|
· <span class="${cls}">${txt}</span>
|
||||||
|
${i.voz ? `<span class="text-muted"> (voz: ${i.voz})</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
cargarVozTV();
|
||||||
|
|
||||||
async function exportarCSV() {
|
async function exportarCSV() {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
desde: document.getElementById('act-desde').value,
|
desde: document.getElementById('act-desde').value,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 20260823_tv_voz_log.sql
|
||||||
|
--
|
||||||
|
-- Bitácora de la voz del televisor. Los fallos son intermitentes ("a veces no
|
||||||
|
-- suena, a veces entrecortado") y nadie puede depurarlos mirando la pantalla:
|
||||||
|
-- hay que registrar cada intento de hablar CUANDO ocurre, con qué voz, si
|
||||||
|
-- arrancó, cuánto tardó y cómo terminó. Con esto, la próxima vez que reporten
|
||||||
|
-- "ayer a las 10 no sonó" se consulta esta tabla y se ve exactamente qué pasó.
|
||||||
|
--
|
||||||
|
-- La escribe modules/turnero/api/log_tv.php vía sendBeacon desde la pantalla.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS turnero_tv_log (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
evento VARCHAR(20) NOT NULL COMMENT 'start, end, error, sin_start, mudo',
|
||||||
|
detalle VARCHAR(500) NULL COMMENT 'JSON: código del turno, voz, ms, duración',
|
||||||
|
ip VARCHAR(45) NULL,
|
||||||
|
user_agent VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_evento_fecha (evento, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -233,6 +234,27 @@ try {
|
|||||||
|
|
||||||
notificarSSE((int) $turnoActualizado['sesion_id']);
|
notificarSSE((int) $turnoActualizado['sesion_id']);
|
||||||
|
|
||||||
|
// Queda constancia de quién cambió el estado a mano. Sin esto, en el
|
||||||
|
// historial aparecía un turno cancelado o ausente sin ninguna huella
|
||||||
|
// de quién lo hizo ni desde qué estado.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'cambiar_estado',
|
||||||
|
$turnoId,
|
||||||
|
[
|
||||||
|
'turno_id' => $turnoId,
|
||||||
|
'turno_codigo' => $turnoActualizado['codigo'] ?? null,
|
||||||
|
'estado_previo' => $estadoActual,
|
||||||
|
'estado_nuevo' => $nuevoEstado,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// La auditoría nunca debe tumbar la operación
|
||||||
|
error_log('[cambiar_estado] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
|
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Body: { consentimiento_id }
|
* Body: { consentimiento_id }
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -30,4 +31,35 @@ $stmt->execute([$turnoId, $consentId]);
|
|||||||
|
|
||||||
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
|
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
|
||||||
|
|
||||||
|
// Cancelar una toma pendiente cierra un protocolo que quedó a medias en otra
|
||||||
|
// visita: conviene saber quién lo dio por terminado.
|
||||||
|
try {
|
||||||
|
$inf = $pdo->prepare(
|
||||||
|
"SELECT tc.turno_id, t.codigo, f.nombre AS formulario
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
|
WHERE tc.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$inf->execute([$consentId]);
|
||||||
|
$d = $inf->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'cancelar_toma_pendiente',
|
||||||
|
$consentId,
|
||||||
|
[
|
||||||
|
// Se registra bajo el turno donde se hizo la cancelación, que es
|
||||||
|
// donde alguien la va a buscar en el historial.
|
||||||
|
'turno_id' => $turnoId,
|
||||||
|
'turno_origen_id' => isset($d['turno_id']) ? (int)$d['turno_id'] : null,
|
||||||
|
'turno_origen' => $d['codigo'] ?? null,
|
||||||
|
'formulario_nombre' => $d['formulario'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[cancelar_toma_pendiente] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['cancelado' => true]);
|
jsonOk(['cancelado' => true]);
|
||||||
|
|||||||
@@ -19,9 +19,8 @@ $svg = $body['svg'] ?? '';
|
|||||||
$soloPro = !empty($body['solo_profesional']);
|
$soloPro = !empty($body['solo_profesional']);
|
||||||
// Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
|
// Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
|
||||||
$datosResp = null;
|
$datosResp = null;
|
||||||
if (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']) && !empty($body['datos_respuestas'])) {
|
$datosArr = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
|
||||||
$datosResp = json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE);
|
? $body['datos_respuestas'] : [];
|
||||||
}
|
|
||||||
|
|
||||||
if (!$turnoId) jsonError('turno_id requerido.');
|
if (!$turnoId) jsonError('turno_id requerido.');
|
||||||
if (!$formularioId) jsonError('formulario_id requerido.');
|
if (!$formularioId) jsonError('formulario_id requerido.');
|
||||||
@@ -32,6 +31,32 @@ if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
|
|||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
|
|
||||||
|
// Quién firma se resuelve aquí y no se toma del navegador. El cliente manda el
|
||||||
|
// nombre que cargó al abrir la página, y si el personal cambia de turno sin
|
||||||
|
// recargar, la firma quedaba a nombre de quien abrió el formulario en vez de
|
||||||
|
// quien la está haciendo. La sesión del servidor es la única fuente confiable.
|
||||||
|
// (guardar_toma.php ya lo hacía así para cada toma; esto alinea la firma final.)
|
||||||
|
$__uid = adminId();
|
||||||
|
if ($__uid) {
|
||||||
|
$stmtPro = $pdo->prepare(
|
||||||
|
"SELECT au.full_name, au.cedula AS au_cedula,
|
||||||
|
le.nombre_completo AS pro_nombre, le.numero_documento AS pro_documento
|
||||||
|
FROM admin_users au
|
||||||
|
LEFT JOIN lab_enfermeras le ON le.id = au.enfermera_id
|
||||||
|
WHERE au.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmtPro->execute([$__uid]);
|
||||||
|
$pro = $stmtPro->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
$proNombre = ($pro['pro_nombre'] ?? null) ?: ($pro['full_name'] ?? null);
|
||||||
|
$proCedula = ($pro['au_cedula'] ?? null) ?: ($pro['pro_documento'] ?? null);
|
||||||
|
if ($proNombre) $datosArr['_pro_nombre'] = $proNombre;
|
||||||
|
if ($proCedula) $datosArr['_pro_cedula'] = $proCedula;
|
||||||
|
$datosArr['_pro_id'] = $__uid;
|
||||||
|
}
|
||||||
|
if (!empty($datosArr)) {
|
||||||
|
$datosResp = json_encode($datosArr, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"SELECT tc.id, tc.estado, t.sesion_id
|
"SELECT tc.id, tc.estado, t.sesion_id
|
||||||
FROM turnero_consentimientos tc
|
FROM turnero_consentimientos tc
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
|
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
|
||||||
* - desglose por prioridad
|
* - desglose por prioridad
|
||||||
* - desglose por lugar
|
* - desglose por lugar
|
||||||
|
* - desglose por recepcionista y por bacteriólogo (quién atendió a cuántos)
|
||||||
* - lista de turnos del día (para tabla detalle)
|
* - lista de turnos del día (para tabla detalle)
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
@@ -41,10 +42,12 @@ if (!$sesion) {
|
|||||||
'tiempo_espera_promedio_min' => null,
|
'tiempo_espera_promedio_min' => null,
|
||||||
'tiempo_servicio_promedio_min' => null,
|
'tiempo_servicio_promedio_min' => null,
|
||||||
],
|
],
|
||||||
'por_prioridad' => [],
|
'por_prioridad' => [],
|
||||||
'por_lugar' => [],
|
'por_lugar' => [],
|
||||||
'consent_stats' => [],
|
'por_recepcionista' => [],
|
||||||
'turnos' => [],
|
'por_bacteriologo' => [],
|
||||||
|
'consent_stats' => [],
|
||||||
|
'turnos' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,36 +57,51 @@ $sesionId = (int)$sesion['id'];
|
|||||||
$stmtRes = $pdo->prepare(
|
$stmtRes = $pdo->prepare(
|
||||||
"SELECT
|
"SELECT
|
||||||
COUNT(*) AS total,
|
COUNT(*) AS total,
|
||||||
SUM(estado IN ('finalizado','en_servicio')) AS atendidos,
|
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
|
||||||
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
||||||
SUM(estado = 'ausente') AS ausentes,
|
SUM(t.estado = 'ausente') AS ausentes,
|
||||||
SUM(estado = 'cancelado') AS cancelados,
|
SUM(t.estado = 'cancelado') AS cancelados,
|
||||||
ROUND(
|
ROUND(
|
||||||
AVG(
|
AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL
|
WHEN t.inicio_recepcion_at IS NOT NULL AND t.creado_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0
|
THEN TIMESTAMPDIFF(SECOND, t.creado_at, t.inicio_recepcion_at) / 60.0
|
||||||
END
|
END
|
||||||
), 1
|
), 1
|
||||||
) AS tiempo_espera_promedio_min,
|
) AS tiempo_espera_promedio_min,
|
||||||
|
-- En los protocolos prolongados la atención se corta en la PRIMERA toma:
|
||||||
|
-- lo que sigue son horas de espera del examen (una curva de glucosa son
|
||||||
|
-- 2 horas), no tiempo de trabajo. Contarlas triplicaba el promedio —21,9
|
||||||
|
-- min contra 7,4— y hacía ver los puestos mucho más lentos de lo que son.
|
||||||
ROUND(
|
ROUND(
|
||||||
AVG(
|
AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL
|
WHEN t.inicio_lugar_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
END
|
END
|
||||||
), 1
|
), 1
|
||||||
) AS tiempo_servicio_promedio_min,
|
) AS tiempo_servicio_promedio_min,
|
||||||
ROUND(
|
ROUND(
|
||||||
AVG(
|
AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL
|
WHEN t.creado_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60.0
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.creado_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
END
|
END
|
||||||
), 1
|
), 1
|
||||||
) AS tiempo_total_promedio_min
|
) AS tiempo_total_promedio_min
|
||||||
FROM turnero_turnos
|
FROM turnero_turnos t
|
||||||
WHERE sesion_id = ?"
|
-- Un turno tiene a lo sumo un protocolo prolongado, así que el join no duplica
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||||
|
GROUP BY tc.turno_id
|
||||||
|
) prot ON prot.turno_id = t.id
|
||||||
|
WHERE t.sesion_id = ?"
|
||||||
);
|
);
|
||||||
$stmtRes->execute([$sesionId]);
|
$stmtRes->execute([$sesionId]);
|
||||||
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
|
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
|
||||||
@@ -112,12 +130,20 @@ $stmtLug = $pdo->prepare(
|
|||||||
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
|
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
|
||||||
ROUND(AVG(
|
ROUND(AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL
|
WHEN t.inicio_lugar_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
END
|
END
|
||||||
), 1) AS tiempo_servicio_promedio_min
|
), 1) AS tiempo_servicio_promedio_min
|
||||||
FROM turnero_turnos t
|
FROM turnero_turnos t
|
||||||
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||||
|
GROUP BY tc.turno_id
|
||||||
|
) prot ON prot.turno_id = t.id
|
||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
GROUP BY l.id
|
GROUP BY l.id
|
||||||
ORDER BY l.sort_order ASC, l.nombre ASC"
|
ORDER BY l.sort_order ASC, l.nombre ASC"
|
||||||
@@ -125,6 +151,63 @@ $stmtLug = $pdo->prepare(
|
|||||||
$stmtLug->execute([$sesionId]);
|
$stmtLug->execute([$sesionId]);
|
||||||
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
|
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// ── 4b. Desglose por recepcionista ────────────────────────────
|
||||||
|
// Mismo molde que el ranking de enfermeras de lab_domicilios
|
||||||
|
// (api/lab/get_metricas.php). atendido_recepcion_por guarda al PRIMER
|
||||||
|
// recepcionista que tomó el turno (COALESCE en cambiar_estado.php).
|
||||||
|
$stmtRec = $pdo->prepare(
|
||||||
|
"SELECT u.id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
|
||||||
|
COUNT(t.id) AS total,
|
||||||
|
SUM(t.estado IN ('finalizado','en_servicio','en_espera_lugar')) AS atendidos,
|
||||||
|
SUM(t.estado = 'ausente') AS ausentes,
|
||||||
|
SUM(t.estado = 'cancelado') AS cancelados,
|
||||||
|
ROUND(AVG(
|
||||||
|
CASE
|
||||||
|
WHEN t.fin_recepcion_at IS NOT NULL AND t.inicio_recepcion_at IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60.0
|
||||||
|
END
|
||||||
|
), 1) AS tiempo_promedio_min
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN admin_users u ON u.id = t.atendido_recepcion_por
|
||||||
|
WHERE t.sesion_id = ?
|
||||||
|
GROUP BY u.id, u.full_name, u.username
|
||||||
|
ORDER BY atendidos DESC, total DESC"
|
||||||
|
);
|
||||||
|
$stmtRec->execute([$sesionId]);
|
||||||
|
$porRecepcionista = $stmtRec->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// ── 4c. Desglose por bacteriólogo (quien atendió en el lugar) ──
|
||||||
|
$stmtBac = $pdo->prepare(
|
||||||
|
"SELECT u.id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
|
||||||
|
COUNT(t.id) AS total,
|
||||||
|
SUM(t.estado = 'finalizado') AS finalizados,
|
||||||
|
SUM(t.estado = 'en_servicio') AS en_servicio,
|
||||||
|
SUM(t.estado = 'ausente') AS ausentes,
|
||||||
|
ROUND(AVG(
|
||||||
|
CASE
|
||||||
|
WHEN t.inicio_lugar_at IS NOT NULL
|
||||||
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
|
END
|
||||||
|
), 1) AS tiempo_promedio_min
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN admin_users u ON u.id = t.atendido_lugar_por
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||||
|
GROUP BY tc.turno_id
|
||||||
|
) prot ON prot.turno_id = t.id
|
||||||
|
WHERE t.sesion_id = ?
|
||||||
|
GROUP BY u.id, u.full_name, u.username
|
||||||
|
ORDER BY finalizados DESC, total DESC"
|
||||||
|
);
|
||||||
|
$stmtBac->execute([$sesionId]);
|
||||||
|
$porBacteriologo = $stmtBac->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// ── 5. Lista de turnos del día ────────────────────────────────
|
// ── 5. Lista de turnos del día ────────────────────────────────
|
||||||
$stmtTurnos = $pdo->prepare(
|
$stmtTurnos = $pdo->prepare(
|
||||||
"SELECT t.id, t.codigo, t.numero, t.estado,
|
"SELECT t.id, t.codigo, t.numero, t.estado,
|
||||||
@@ -157,22 +240,47 @@ $stmtTurnos = $pdo->prepare(
|
|||||||
$stmtTurnos->execute([$sesionId]);
|
$stmtTurnos->execute([$sesionId]);
|
||||||
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// Enriquecer con exámenes y comentarios
|
// Enriquecer con exámenes y comentarios — en lote.
|
||||||
foreach ($turnos as &$turno) {
|
// Antes eran 2 consultas por turno dentro del foreach (N+1): con 80 turnos en
|
||||||
|
// el día eran 160 consultas evitables. Mismo patrón que get_historial.php.
|
||||||
|
$turnoIds = array_column($turnos, 'id');
|
||||||
|
$examenesPorTurno = [];
|
||||||
|
$comentariosPorTurno = [];
|
||||||
|
|
||||||
|
if ($turnoIds) {
|
||||||
|
$ph = implode(',', array_fill(0, count($turnoIds), '?'));
|
||||||
|
|
||||||
$stmtEx = $pdo->prepare(
|
$stmtEx = $pdo->prepare(
|
||||||
"SELECT DISTINCT et.nombre FROM turnero_examen_items tei
|
"SELECT DISTINCT ts.turno_id, et.nombre
|
||||||
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
FROM turnero_solicitudes ts
|
||||||
WHERE tei.solicitud_id = (SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1)"
|
JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
|
||||||
|
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||||
|
WHERE ts.turno_id IN ($ph)
|
||||||
|
ORDER BY et.nombre ASC"
|
||||||
);
|
);
|
||||||
$stmtEx->execute([$turno['id']]);
|
$stmtEx->execute($turnoIds);
|
||||||
$turno['examenes'] = $stmtEx->fetchAll(PDO::FETCH_ASSOC);
|
foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$examenesPorTurno[(int)$row['turno_id']][] = ['nombre' => $row['nombre']];
|
||||||
|
}
|
||||||
|
|
||||||
$stmtCom = $pdo->prepare(
|
$stmtCom = $pdo->prepare(
|
||||||
"SELECT usuario_nombre, comentario, tipo, creado_at
|
"SELECT turno_id, usuario_nombre, comentario, tipo, creado_at
|
||||||
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at ASC"
|
FROM turnero_comentarios
|
||||||
|
WHERE turno_id IN ($ph)
|
||||||
|
ORDER BY turno_id, creado_at ASC"
|
||||||
);
|
);
|
||||||
$stmtCom->execute([$turno['id']]);
|
$stmtCom->execute($turnoIds);
|
||||||
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
|
foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$tid = (int)$row['turno_id'];
|
||||||
|
unset($row['turno_id']);
|
||||||
|
$comentariosPorTurno[$tid][] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($turnos as &$turno) {
|
||||||
|
$tid = (int)$turno['id'];
|
||||||
|
$turno['examenes'] = $examenesPorTurno[$tid] ?? [];
|
||||||
|
$turno['comentarios'] = $comentariosPorTurno[$tid] ?? [];
|
||||||
}
|
}
|
||||||
unset($turno);
|
unset($turno);
|
||||||
|
|
||||||
@@ -201,6 +309,8 @@ jsonOk([
|
|||||||
'resumen' => $resumen,
|
'resumen' => $resumen,
|
||||||
'por_prioridad' => $porPrioridad,
|
'por_prioridad' => $porPrioridad,
|
||||||
'por_lugar' => $porLugar,
|
'por_lugar' => $porLugar,
|
||||||
|
'por_recepcionista' => $porRecepcionista,
|
||||||
|
'por_bacteriologo' => $porBacteriologo,
|
||||||
'consent_stats' => $consentStats,
|
'consent_stats' => $consentStats,
|
||||||
'turnos' => $turnos,
|
'turnos' => $turnos,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* GET Historial paginado de turnos con filtros multi-día.
|
* GET Historial paginado de turnos con filtros multi-día.
|
||||||
*
|
*
|
||||||
* Parámetros (todos opcionales):
|
* Parámetros (todos opcionales):
|
||||||
* fecha_desde YYYY-MM-DD (default: hace 7 días)
|
* fecha_desde YYYY-MM-DD (default: hoy; hace 12 meses si se pasa paciente_id)
|
||||||
* fecha_hasta YYYY-MM-DD (default: hoy)
|
* fecha_hasta YYYY-MM-DD (default: hoy)
|
||||||
* estado string ("finalizado","ausente","cancelado","en_servicio",... o vacío = todos)
|
* estado string ("finalizado","ausente","cancelado","en_servicio",... o vacío = todos)
|
||||||
* lugar_id int (0 = todos)
|
* lugar_id int (0 = todos)
|
||||||
@@ -23,16 +23,29 @@ $pdo = db();
|
|||||||
// ── Parámetros ────────────────────────────────────────────────
|
// ── Parámetros ────────────────────────────────────────────────
|
||||||
$hoy = date('Y-m-d');
|
$hoy = date('Y-m-d');
|
||||||
|
|
||||||
|
// paciente_id se lee primero: determina el rango de fechas por defecto.
|
||||||
|
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
|
||||||
|
|
||||||
$fechaDesde = trim($_GET['fecha_desde'] ?? '');
|
$fechaDesde = trim($_GET['fecha_desde'] ?? '');
|
||||||
$fechaHasta = trim($_GET['fecha_hasta'] ?? '');
|
$fechaHasta = trim($_GET['fecha_hasta'] ?? '');
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $hoy;
|
|
||||||
|
// Rango por defecto cuando el cliente no manda fechas:
|
||||||
|
// - Ficha de paciente (paciente_id): ultimos 12 meses.
|
||||||
|
// Antes caia en "hoy", asi que el historial del paciente solo podia mostrar
|
||||||
|
// visitas del mismo dia y siempre respondia "sin visitas anteriores".
|
||||||
|
// - Listado general del historial: hoy (comportamiento original).
|
||||||
|
// Para ver el historial completo, el cliente pasa fecha_desde explicita.
|
||||||
|
$desdePorDefecto = $pacienteId > 0
|
||||||
|
? date('Y-m-d', strtotime('-12 months'))
|
||||||
|
: $hoy;
|
||||||
|
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $desdePorDefecto;
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaHasta)) $fechaHasta = $hoy;
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaHasta)) $fechaHasta = $hoy;
|
||||||
if ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
|
if ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
|
||||||
|
|
||||||
$estadoFiltro = trim($_GET['estado'] ?? '');
|
$estadoFiltro = trim($_GET['estado'] ?? '');
|
||||||
$lugarId = (int)($_GET['lugar_id'] ?? 0);
|
$lugarId = (int)($_GET['lugar_id'] ?? 0);
|
||||||
$prioridadId = (int)($_GET['prioridad_id'] ?? 0);
|
$prioridadId = (int)($_GET['prioridad_id'] ?? 0);
|
||||||
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
|
|
||||||
$q = trim($_GET['q'] ?? '');
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
|
||||||
$perPage = min(200, max(10, (int)($_GET['per_page'] ?? 50)));
|
$perPage = min(200, max(10, (int)($_GET['per_page'] ?? 50)));
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /modules/turnero/api/get_traza_turno.php?turno_id=N
|
||||||
|
*
|
||||||
|
* Bitácora de un turno: qué pasó, cuándo y quién lo hizo.
|
||||||
|
*
|
||||||
|
* La información ya existía, pero repartida en cinco tablas y sin ninguna
|
||||||
|
* pantalla que la juntara: para saber quién retiró un formulario había que
|
||||||
|
* consultar la base a mano. Aquí se unifica en una sola línea de tiempo.
|
||||||
|
*
|
||||||
|
* No inventa nada: cada evento sale de un dato guardado. Lo que el sistema no
|
||||||
|
* registró —ver `sinRastro` más abajo— simplemente no aparece.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireMethod('GET');
|
||||||
|
requireTurnero();
|
||||||
|
|
||||||
|
$turnoId = (int)($_GET['turno_id'] ?? 0);
|
||||||
|
if (!$turnoId) jsonError('turno_id requerido');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$t = $pdo->prepare(
|
||||||
|
"SELECT t.*, l.nombre AS lugar_nombre, d.nombre AS desk_nombre,
|
||||||
|
ur.full_name AS recepcionista, ul.full_name AS profesional
|
||||||
|
FROM turnero_turnos t
|
||||||
|
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||||
|
LEFT JOIN turnero_lugares d ON d.id = t.recepcion_desk_id
|
||||||
|
LEFT JOIN admin_users ur ON ur.id = t.atendido_recepcion_por
|
||||||
|
LEFT JOIN admin_users ul ON ul.id = t.atendido_lugar_por
|
||||||
|
WHERE t.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$t->execute([$turnoId]);
|
||||||
|
$turno = $t->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$turno) jsonError('Turno no encontrado', 404);
|
||||||
|
|
||||||
|
$ev = [];
|
||||||
|
$add = function ($cuando, $tipo, $texto, $quien = null, $extra = null) use (&$ev) {
|
||||||
|
if (!$cuando) return;
|
||||||
|
$ev[] = [
|
||||||
|
'cuando' => $cuando,
|
||||||
|
'tipo' => $tipo, // la vista lo usa para el ícono y el color
|
||||||
|
'texto' => $texto,
|
||||||
|
'quien' => $quien ?: null,
|
||||||
|
'extra' => $extra ?: null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 1. Recorrido del turno ────────────────────────────────────────────────
|
||||||
|
$add($turno['creado_at'], 'turno', 'Turno creado');
|
||||||
|
$add($turno['llamado_recepcion_at'], 'llamado', 'Llamado a ' . ($turno['desk_nombre'] ?: 'recepción'));
|
||||||
|
$add($turno['inicio_recepcion_at'], 'atencion', 'Inicia atención en recepción', $turno['recepcionista']);
|
||||||
|
$add($turno['fin_recepcion_at'], 'atencion', 'Termina recepción');
|
||||||
|
$add($turno['llamado_lugar_at'], 'llamado', 'Llamado a ' . ($turno['lugar_nombre'] ?: 'toma de muestras'));
|
||||||
|
$add($turno['inicio_lugar_at'], 'atencion', 'Inicia toma de muestras', $turno['profesional']);
|
||||||
|
$add($turno['muestra_espera_at'], 'muestra', 'Pasa a espera de muestra');
|
||||||
|
$add($turno['fin_lugar_at'], 'fin', 'Finaliza la atención');
|
||||||
|
|
||||||
|
// ── 2. Documentos ─────────────────────────────────────────────────────────
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT tc.enviado_at, tc.firmado_at, tc.firmado_profesional_at, tc.estado,
|
||||||
|
tc.firma_svg, tc.firma_profesional_svg, tc.datos_respuestas,
|
||||||
|
f.nombre AS formulario, f.es_toma_progresiva
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
|
WHERE tc.turno_id = ?"
|
||||||
|
);
|
||||||
|
$q->execute([$turnoId]);
|
||||||
|
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $c) {
|
||||||
|
$add($c['enviado_at'], 'doc', 'Enviado: ' . $c['formulario']);
|
||||||
|
|
||||||
|
// No todos los formularios los firma el paciente: el F-LAB-08 solo lo firma
|
||||||
|
// el profesional y el F-LAB-28 lleva una firma por toma. Se mira qué firma
|
||||||
|
// hay guardada en vez de suponer que firmado_at es del paciente, que era lo
|
||||||
|
// que antes hacía decir «firmado por el paciente» en documentos que el
|
||||||
|
// paciente nunca firmó.
|
||||||
|
$firmoPaciente = !empty($c['firma_svg']);
|
||||||
|
$firmoProfesional = !empty($c['firma_profesional_svg']);
|
||||||
|
|
||||||
|
if ($firmoPaciente) {
|
||||||
|
$add($c['firmado_at'], 'doc_ok', 'Firmado por el paciente: ' . $c['formulario']);
|
||||||
|
}
|
||||||
|
if ($firmoProfesional) {
|
||||||
|
$add($c['firmado_profesional_at'] ?: $c['firmado_at'], 'doc_ok',
|
||||||
|
'Firmado por el profesional: ' . $c['formulario']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocolo prolongado: cada toma lleva su hora y su firmante, que es el
|
||||||
|
// dato que de verdad interesa. La firma general no dice quién pinchó.
|
||||||
|
if (!empty($c['es_toma_progresiva'])) {
|
||||||
|
$dr = json_decode($c['datos_respuestas'] ?? '{}', true) ?: [];
|
||||||
|
$tomas = [];
|
||||||
|
foreach ($dr as $clave => $valor) {
|
||||||
|
// Las firmas de toma terminan en _f y traen su hora en _h
|
||||||
|
if (!preg_match('/^(_tm[a-z0-9]+)_f$/', $clave, $m)) continue;
|
||||||
|
if (!is_string($valor) || strlen($valor) < 10) continue;
|
||||||
|
$base = $m[1];
|
||||||
|
$tomas[] = [
|
||||||
|
'hora' => $dr[$base . '_h'] ?? null,
|
||||||
|
// _tm00 → «Minuto 0», _tm150 → «Minuto 150»
|
||||||
|
'etiqueta' => 'Minuto ' . (int) substr($base, 3),
|
||||||
|
'firmante' => $dr[$clave . '_pro_nombre'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Se ordenan por hora para que la línea de tiempo quede coherente
|
||||||
|
usort($tomas, fn($a, $b) => strcmp((string)$a['hora'], (string)$b['hora']));
|
||||||
|
foreach ($tomas as $t) {
|
||||||
|
if (!$t['hora']) continue;
|
||||||
|
// La hora viene como "HH:MM"; se ancla al día del turno
|
||||||
|
$cuando = substr($turno['creado_at'], 0, 10) . ' ' . $t['hora'] . ':00';
|
||||||
|
$add($cuando, 'muestra',
|
||||||
|
'Toma tomada y firmada (' . $t['etiqueta'] . ')',
|
||||||
|
$t['firmante']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$firmoPaciente && !$firmoProfesional && $c['firmado_at'] && empty($c['es_toma_progresiva'])) {
|
||||||
|
$add($c['firmado_at'], 'doc_ok', 'Completado: ' . $c['formulario']);
|
||||||
|
}
|
||||||
|
if (!empty($c['es_toma_progresiva']) && $c['firmado_at']) {
|
||||||
|
$add($c['firmado_at'], 'fin', 'Protocolo cerrado: ' . $c['formulario']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Comentarios del personal ───────────────────────────────────────────
|
||||||
|
// Aquí caen también las ausencias, que se guardan como comentario con su motivo.
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT creado_at, usuario_nombre, comentario, tipo
|
||||||
|
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at"
|
||||||
|
);
|
||||||
|
$q->execute([$turnoId]);
|
||||||
|
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $c) {
|
||||||
|
$add($c['creado_at'], 'comentario', $c['comentario'], $c['usuario_nombre'], $c['tipo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Muestras ───────────────────────────────────────────────────────────
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT m.recibida_at, m.tipo_muestra, m.estado, m.motivo_rechazo,
|
||||||
|
u.full_name AS quien
|
||||||
|
FROM turnero_muestras m
|
||||||
|
LEFT JOIN admin_users u ON u.id = m.recibida_por
|
||||||
|
WHERE m.recibida_en_turno_id = ?"
|
||||||
|
);
|
||||||
|
$q->execute([$turnoId]);
|
||||||
|
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $m) {
|
||||||
|
$texto = $m['estado'] === 'rechazada'
|
||||||
|
? 'Muestra RECHAZADA (' . $m['tipo_muestra'] . '): ' . ($m['motivo_rechazo'] ?: 'sin motivo')
|
||||||
|
: 'Muestra recibida (' . $m['tipo_muestra'] . ')';
|
||||||
|
$add($m['recibida_at'], $m['estado'] === 'rechazada' ? 'alerta' : 'muestra', $texto, $m['quien']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Acciones administrativas auditadas ─────────────────────────────────
|
||||||
|
// El detalle es JSON; se filtra por turno en PHP para no depender de LIKE.
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT created_at, admin_nombre, accion, detalle
|
||||||
|
FROM lab_actividad_admin
|
||||||
|
WHERE modulo IN ('turnero','turnero_config') AND detalle LIKE ?"
|
||||||
|
);
|
||||||
|
$q->execute(['%"turno_id":' . $turnoId . '%']);
|
||||||
|
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||||
|
$d = json_decode($a['detalle'], true) ?: [];
|
||||||
|
if ((int)($d['turno_id'] ?? 0) !== $turnoId) continue; // evita coincidencias por prefijo
|
||||||
|
|
||||||
|
// Cada acción se traduce a algo que se entienda leyéndolo, no al nombre
|
||||||
|
// técnico del endpoint.
|
||||||
|
switch ($a['accion']) {
|
||||||
|
case 'quitar_consentimiento':
|
||||||
|
$add($a['created_at'], 'alerta',
|
||||||
|
'Formulario RETIRADO: ' . ($d['formulario_nombre'] ?? '?'),
|
||||||
|
$a['admin_nombre'],
|
||||||
|
'Motivo: ' . ($d['motivo'] ?? 'sin motivo')
|
||||||
|
. ' · estaba en «' . ($d['estado_previo'] ?? '?') . '»');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cambiar_estado':
|
||||||
|
$add($a['created_at'], 'admin',
|
||||||
|
'Estado cambiado a mano: ' . ($d['estado_previo'] ?? '?')
|
||||||
|
. ' → ' . ($d['estado_nuevo'] ?? '?'),
|
||||||
|
$a['admin_nombre']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'resetear_consentimiento':
|
||||||
|
$add($a['created_at'], 'alerta',
|
||||||
|
'Formulario REINICIADO: ' . ($d['formulario_nombre'] ?? '?'),
|
||||||
|
$a['admin_nombre'],
|
||||||
|
!empty($d['tenia_firma'])
|
||||||
|
? 'Se descartó una firma ya hecha'
|
||||||
|
: 'No tenía firma todavía');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'resetear_toma':
|
||||||
|
$n = is_array($d['campos_olvidados'] ?? null) ? count($d['campos_olvidados']) : 0;
|
||||||
|
$add($a['created_at'], 'alerta',
|
||||||
|
'Toma olvidada en ' . ($d['formulario_nombre'] ?? 'protocolo'),
|
||||||
|
$a['admin_nombre'],
|
||||||
|
$n ? "Se descartaron $n dato(s) de la toma" : null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cancelar_toma_pendiente':
|
||||||
|
$add($a['created_at'], 'alerta',
|
||||||
|
'Cancelada la toma pendiente de ' . ($d['turno_origen'] ?? 'otra visita'),
|
||||||
|
$a['admin_nombre'],
|
||||||
|
$d['formulario_nombre'] ?? null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'llamar_turno':
|
||||||
|
$add($a['created_at'], 'llamado', 'Llamó al paciente', $a['admin_nombre']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rellamar':
|
||||||
|
$add($a['created_at'], 'llamado', 'Volvió a llamar al paciente', $a['admin_nombre']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'vincular_paciente':
|
||||||
|
$add($a['created_at'], 'admin',
|
||||||
|
'Paciente vinculado: ' . ($d['paciente_nombre'] ?? '#' . ($d['paciente_id'] ?? '?')),
|
||||||
|
$a['admin_nombre']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$add($a['created_at'], 'admin', $a['accion'], $a['admin_nombre']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($ev, fn($a, $b) => strcmp($a['cuando'], $b['cuando']));
|
||||||
|
|
||||||
|
// ── 6. Conversación de WhatsApp ───────────────────────────────────────────
|
||||||
|
// Solo el conteo del día y el enlace al chat: volcar los mensajes aquí sería
|
||||||
|
// esparcir datos personales del paciente por una pantalla de consulta.
|
||||||
|
$chat = null;
|
||||||
|
if (!empty($turno['paciente_id'])) {
|
||||||
|
$q = $pdo->prepare(
|
||||||
|
"SELECT u.id AS user_id,
|
||||||
|
(SELECT COUNT(*) FROM conversations c
|
||||||
|
WHERE c.user_id = u.id AND DATE(c.created_at) = DATE(?)) AS mensajes
|
||||||
|
FROM lab_pacientes p JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$q->execute([$turno['creado_at'], (int)$turno['paciente_id']]);
|
||||||
|
$r = $q->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($r && (int)$r['mensajes'] > 0) {
|
||||||
|
$chat = ['user_id' => (int)$r['user_id'], 'mensajes' => (int)$r['mensajes']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOk([
|
||||||
|
'turno' => [
|
||||||
|
'id' => (int)$turno['id'],
|
||||||
|
'codigo' => $turno['codigo'],
|
||||||
|
'estado' => $turno['estado'],
|
||||||
|
'paciente' => $turno['paciente_nombre'],
|
||||||
|
],
|
||||||
|
'eventos' => $ev,
|
||||||
|
'chat' => $chat,
|
||||||
|
// Se declara lo que el sistema NO registra, para que nadie lea la ausencia
|
||||||
|
// de un evento como prueba de que no ocurrió.
|
||||||
|
// Lo que el sistema sigue sin registrar. Se declara para que la ausencia de
|
||||||
|
// un evento no se lea como prueba de que no ocurrió.
|
||||||
|
'sinRastro' => [
|
||||||
|
'el llamado original de los turnos anteriores a hoy, que el rellamado sobrescribía',
|
||||||
|
],
|
||||||
|
]);
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -144,6 +145,20 @@ try {
|
|||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
notificarSSE((int) $turnoActualizado['sesion_id']);
|
notificarSSE((int) $turnoActualizado['sesion_id']);
|
||||||
|
|
||||||
|
// Quién llamó no se guardaba en ninguna parte: el turno solo conserva la hora
|
||||||
|
// del último llamado, así que en la bitácora aparecía «Llamado a X» sin autor.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(), 'turnero', 'llamar_turno', $turnoId,
|
||||||
|
[
|
||||||
|
'turno_id' => $turnoId,
|
||||||
|
'turno_codigo' => $turnoActualizado['codigo'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[llamar_turno] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado");
|
jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado");
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /modules/turnero/api/log_tv.php
|
||||||
|
* Recibe la bitácora de voz de la pantalla del televisor (via sendBeacon).
|
||||||
|
*
|
||||||
|
* SIN SESIÓN, a propósito: la pantalla es un kiosco público (display_global es
|
||||||
|
* ruta pública) y el envío es fire-and-forget. Por eso el endpoint es
|
||||||
|
* deliberadamente sordo y estrecho:
|
||||||
|
* - solo acepta los eventos de la lista, nada libre;
|
||||||
|
* - recorta el detalle a 500 caracteres;
|
||||||
|
* - no devuelve datos, solo un 204;
|
||||||
|
* - inserta y nada más: no lee, no borra, no actualiza.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
|
http_response_code(204);
|
||||||
|
header('Content-Type: text/plain');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') exit;
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw === false || strlen($raw) > 2000) exit;
|
||||||
|
|
||||||
|
$d = json_decode($raw, true);
|
||||||
|
if (!is_array($d)) exit;
|
||||||
|
|
||||||
|
$evento = $d['e'] ?? '';
|
||||||
|
$PERMITIDOS = ['start', 'end', 'error', 'sin_start', 'mudo', 'lag'];
|
||||||
|
if (!in_array($evento, $PERMITIDOS, true)) exit;
|
||||||
|
|
||||||
|
unset($d['e'], $d['t']); // el timestamp lo pone la base, no el cliente
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"INSERT INTO turnero_tv_log (evento, detalle, ip, user_agent) VALUES (?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$evento,
|
||||||
|
mb_substr(json_encode($d, JSON_UNESCAPED_UNICODE), 0, 500),
|
||||||
|
substr(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]), 0, 45),
|
||||||
|
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// La bitácora jamás debe afectar a la pantalla
|
||||||
|
error_log('[log_tv] ' . $e->getMessage());
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -48,4 +49,15 @@ $stmtT = $pdo->prepare(
|
|||||||
$stmtT->execute([$turnoId]);
|
$stmtT->execute([$turnoId]);
|
||||||
$turno = $stmtT->fetch(PDO::FETCH_ASSOC);
|
$turno = $stmtT->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// El rellamado pisa llamado_recepcion_at, así que sin esto no queda rastro de
|
||||||
|
// cuántas veces se llamó al paciente ni quién lo hizo.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(), 'turnero', 'rellamar', $turnoId,
|
||||||
|
['turno_id' => $turnoId, 'turno_codigo' => $turno['codigo'] ?? null]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[rellamar] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['turno' => $turno]);
|
jsonOk(['turno' => $turno]);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Body JSON: { token: string }
|
* Body JSON: { token: string }
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -14,9 +15,11 @@ if (!$token) jsonError('token requerido.');
|
|||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"SELECT tc.id, t.sesion_id
|
"SELECT tc.id, tc.turno_id, tc.estado, tc.firmado_at, t.sesion_id, t.codigo,
|
||||||
|
f.nombre AS formulario
|
||||||
FROM turnero_consentimientos tc
|
FROM turnero_consentimientos tc
|
||||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
WHERE tc.token = ? LIMIT 1"
|
WHERE tc.token = ? LIMIT 1"
|
||||||
);
|
);
|
||||||
$stmt->execute([$token]);
|
$stmt->execute([$token]);
|
||||||
@@ -34,4 +37,25 @@ $pdo->prepare(
|
|||||||
)->execute([$token]);
|
)->execute([$token]);
|
||||||
|
|
||||||
notificarSSE((int)$tc['sesion_id']);
|
notificarSSE((int)$tc['sesion_id']);
|
||||||
|
|
||||||
|
// Esta acción descarta una firma ya hecha, así que tiene que dejar constancia:
|
||||||
|
// se anota qué documento era, en qué estado estaba y si tenía firma.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'resetear_consentimiento',
|
||||||
|
(int)$tc['id'],
|
||||||
|
[
|
||||||
|
'turno_id' => (int)$tc['turno_id'],
|
||||||
|
'turno_codigo' => $tc['codigo'],
|
||||||
|
'formulario_nombre' => $tc['formulario'],
|
||||||
|
'estado_previo' => $tc['estado'],
|
||||||
|
'tenia_firma' => !empty($tc['firmado_at']),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[resetear_consentimiento] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk([], 'Consentimiento reiniciado correctamente.');
|
jsonOk([], 'Consentimiento reiniciado correctamente.');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Body JSON: { token: string, campos: string[] }
|
* Body JSON: { token: string, campos: string[] }
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -17,9 +18,11 @@ if (!is_array($campos) || empty($campos)) jsonError('campos requerido.');
|
|||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"SELECT tc.id, tc.datos_respuestas, tc.estado, t.sesion_id
|
"SELECT tc.id, tc.turno_id, tc.datos_respuestas, tc.estado, t.sesion_id, t.codigo,
|
||||||
|
f.nombre AS formulario
|
||||||
FROM turnero_consentimientos tc
|
FROM turnero_consentimientos tc
|
||||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
WHERE tc.token = ? LIMIT 1"
|
WHERE tc.token = ? LIMIT 1"
|
||||||
);
|
);
|
||||||
$stmt->execute([$token]);
|
$stmt->execute([$token]);
|
||||||
@@ -40,4 +43,24 @@ $pdo->prepare(
|
|||||||
)->execute([json_encode($dr, JSON_UNESCAPED_UNICODE), $token]);
|
)->execute([json_encode($dr, JSON_UNESCAPED_UNICODE), $token]);
|
||||||
|
|
||||||
notificarSSE((int)$tc['sesion_id']);
|
notificarSSE((int)$tc['sesion_id']);
|
||||||
|
|
||||||
|
// Olvidar una toma borra datos de un protocolo prolongado en curso: se registra
|
||||||
|
// qué campos se descartaron, porque después no hay forma de saberlo.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'resetear_toma',
|
||||||
|
(int)$tc['id'],
|
||||||
|
[
|
||||||
|
'turno_id' => (int)$tc['turno_id'],
|
||||||
|
'turno_codigo' => $tc['codigo'],
|
||||||
|
'formulario_nombre' => $tc['formulario'],
|
||||||
|
'campos_olvidados' => $campos,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[resetear_toma] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk([], 'Toma olvidada.');
|
jsonOk([], 'Toma olvidada.');
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ header('Cache-Control: no-store, no-cache');
|
|||||||
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
|
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
|
||||||
header('Connection: keep-alive');
|
header('Connection: keep-alive');
|
||||||
|
|
||||||
|
// El arranque del ERP deja un búfer de salida activo (los jsonOk() de
|
||||||
|
// _helpers hacen ob_clean(), que lo confirma). flush() a secas no lo
|
||||||
|
// atraviesa: TODO lo emitido quedaba atrapado y el cliente recibía cero
|
||||||
|
// bytes, para siempre. Por eso este endpoint parecía muerto en producción.
|
||||||
|
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||||
|
@ob_implicit_flush(true);
|
||||||
|
|
||||||
// ── Parámetros ────────────────────────────────────────────────
|
// ── Parámetros ────────────────────────────────────────────────
|
||||||
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
|
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
|
||||||
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
|
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Vincula un paciente al turno para que los consentimientos puedan crearse.
|
* Vincula un paciente al turno para que los consentimientos puedan crearse.
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -27,4 +28,22 @@ $stmt->execute([$pacienteId, $nombreCompleto, $turnoId]);
|
|||||||
|
|
||||||
if (!$stmt->rowCount()) jsonError('Turno no encontrado.', 404);
|
if (!$stmt->rowCount()) jsonError('Turno no encontrado.', 404);
|
||||||
|
|
||||||
|
// Vincular a la persona equivocada manda la atención a otra historia clínica,
|
||||||
|
// así que queda registrado quién hizo la vinculación.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'vincular_paciente',
|
||||||
|
$turnoId,
|
||||||
|
[
|
||||||
|
'turno_id' => $turnoId,
|
||||||
|
'paciente_id' => $pacienteId,
|
||||||
|
'paciente_nombre' => $nombreCompleto,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[vincular_paciente] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['turno_id' => $turnoId, 'paciente_id' => $pacienteId]);
|
jsonOk(['turno_id' => $turnoId, 'paciente_id' => $pacienteId]);
|
||||||
|
|||||||
@@ -251,6 +251,9 @@ Layout::open('Bandeja del día', 'fas fa-layer-group');
|
|||||||
.bnd-com-user { font-weight: 600; color: #1e293b; }
|
.bnd-com-user { font-weight: 600; color: #1e293b; }
|
||||||
.bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; }
|
.bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; }
|
||||||
.bnd-com-text { color: #334155; }
|
.bnd-com-text { color: #334155; }
|
||||||
|
.bnd-traza { border-left:2px solid #e2e8f0; margin-left:4px; padding-left:10px; }
|
||||||
|
.bnd-traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.8rem; color:#334155; }
|
||||||
|
.bnd-traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
|
||||||
/* Agregar comentario */
|
/* Agregar comentario */
|
||||||
.bnd-add-com { display: flex; gap: 8px; align-items: flex-end; }
|
.bnd-add-com { display: flex; gap: 8px; align-items: flex-end; }
|
||||||
.bnd-add-com textarea {
|
.bnd-add-com textarea {
|
||||||
@@ -702,7 +705,50 @@ function _renderDetalle(d) {
|
|||||||
<button onclick="_bndEnviarCom(${t.id})"><i class="fas fa-paper-plane"></i> Enviar</button>
|
<button onclick="_bndEnviarCom(${t.id})"><i class="fas fa-paper-plane"></i> Enviar</button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
|
|
||||||
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom;
|
const secTraza = `<div class="bnd-sec">
|
||||||
|
<div class="bnd-sec-title"><i class="fas fa-clock-rotate-left"></i> Qué pasó con este turno</div>
|
||||||
|
<div id="bnd-traza"><span style="color:#94a3b8;font-size:.82rem">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> Cargando…</span></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom + secTraza;
|
||||||
|
_bndCargarTraza(t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bitácora del turno ────────────────────────────────────────
|
||||||
|
// Los comentarios ya salen arriba; aquí va el recorrido completo, incluidas las
|
||||||
|
// acciones administrativas (por ejemplo el retiro de un formulario, con motivo).
|
||||||
|
const BND_TRAZA_ICO = {
|
||||||
|
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
|
||||||
|
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
|
||||||
|
doc_ok:'fa-file-signature', comentario:'fa-comment',
|
||||||
|
alerta:'fa-triangle-exclamation', admin:'fa-user-shield',
|
||||||
|
};
|
||||||
|
const BND_TRAZA_COLOR = { alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb', fin:'#0f766e', admin:'#7c3aed' };
|
||||||
|
|
||||||
|
async function _bndCargarTraza(turnoId) {
|
||||||
|
const cont = document.getElementById('bnd-traza');
|
||||||
|
if (!cont) return;
|
||||||
|
const esc = x => String(x ?? '').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||||
|
try {
|
||||||
|
const j = await fetch(`modules/turnero/api/get_traza_turno.php?turno_id=${turnoId}`).then(r=>r.json());
|
||||||
|
if (!j.ok || !j.eventos?.length) {
|
||||||
|
cont.innerHTML = '<span style="color:#94a3b8;font-size:.82rem">Sin registros</span>'; return;
|
||||||
|
}
|
||||||
|
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
|
||||||
|
cont.innerHTML = '<div class="bnd-traza">' + j.eventos.map(e => `
|
||||||
|
<div class="bnd-traza-fila">
|
||||||
|
<span class="bnd-traza-hora">${hora(e.cuando)}</span>
|
||||||
|
<i class="fas ${BND_TRAZA_ICO[e.tipo]||'fa-circle'}"
|
||||||
|
style="color:${BND_TRAZA_COLOR[e.tipo]||'#94a3b8'};width:14px;font-size:.72rem;margin-top:2px"></i>
|
||||||
|
<div style="flex:1">${esc(e.texto)}
|
||||||
|
${e.quien ? `<span style="color:#64748b;font-style:italic"> ${esc(e.quien)}</span>` : ''}
|
||||||
|
${e.extra ? `<div style="color:#b45309;font-size:.74rem">${esc(e.extra)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`).join('') + '</div>';
|
||||||
|
} catch (_) {
|
||||||
|
cont.innerHTML = '<span style="color:#94a3b8;font-size:.82rem">Error al cargar</span>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Marcar visto ──────────────────────────────────────────────
|
// ── Marcar visto ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -578,6 +578,10 @@ let state = {
|
|||||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadContacts();
|
loadContacts();
|
||||||
|
// Permite llegar directo a una conversación desde otra pantalla, por ejemplo
|
||||||
|
// desde la bitácora del turno en el historial: ?v=chat&user_id=123
|
||||||
|
const _uid = parseInt(new URLSearchParams(location.search).get('user_id') || '', 10);
|
||||||
|
if (_uid > 0) openChat(_uid);
|
||||||
document.getElementById('searchInput').addEventListener('input', debounce(() => {
|
document.getElementById('searchInput').addEventListener('input', debounce(() => {
|
||||||
loadContacts(document.getElementById('searchInput').value.trim());
|
loadContacts(document.getElementById('searchInput').value.trim());
|
||||||
}, 300));
|
}, 300));
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
|
|||||||
.kpi-card .kpi-val { font-size:2rem; font-weight:800; line-height:1.1; }
|
.kpi-card .kpi-val { font-size:2rem; font-weight:800; line-height:1.1; }
|
||||||
.kpi-card .kpi-lbl { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#64748b; margin-top:4px; }
|
.kpi-card .kpi-lbl { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#64748b; margin-top:4px; }
|
||||||
.kpi-card .kpi-sub { font-size:.75rem; color:#94a3b8; margin-top:2px; }
|
.kpi-card .kpi-sub { font-size:.75rem; color:#94a3b8; margin-top:2px; }
|
||||||
|
.kpi-ayuda { font-size:.62rem; color:#94a3b8; line-height:1.3; margin-top:5px;
|
||||||
|
border-top:1px solid #f1f5f9; padding-top:5px; }
|
||||||
|
|
||||||
/* ── Charts / tables section ── */
|
/* ── Charts / tables section ── */
|
||||||
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; }
|
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; }
|
||||||
@@ -287,6 +289,18 @@ try {
|
|||||||
<div class="lugar-grid" id="lugarGrid"></div>
|
<div class="lugar-grid" id="lugarGrid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Desglose por recepcionista -->
|
||||||
|
<div class="section-card" id="sectionRecepcionista" style="display:none">
|
||||||
|
<div class="section-title"><i class="fas fa-user-check me-1"></i>Por recepcionista</div>
|
||||||
|
<div class="lugar-grid" id="recepcionistaGrid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desglose por bacteriólogo -->
|
||||||
|
<div class="section-card" id="sectionBacteriologo" style="display:none">
|
||||||
|
<div class="section-title"><i class="fas fa-user-nurse me-1"></i>Por bacteriólogo</div>
|
||||||
|
<div class="lugar-grid" id="bacteriologoGrid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tabla detalle de turnos -->
|
<!-- Tabla detalle de turnos -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
@@ -445,7 +459,8 @@ function mostrarError(msg) {
|
|||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────
|
||||||
function renderDashboard(json) {
|
function renderDashboard(json) {
|
||||||
const { sesion, resumen, por_prioridad, por_lugar, consent_stats = {}, turnos } = json;
|
const { sesion, resumen, por_prioridad, por_lugar, consent_stats = {}, turnos,
|
||||||
|
por_recepcionista = [], por_bacteriologo = [] } = json;
|
||||||
|
|
||||||
// ── Sesión info ────────────────────────────────────────
|
// ── Sesión info ────────────────────────────────────────
|
||||||
const si = document.getElementById('sesionInfo');
|
const si = document.getElementById('sesionInfo');
|
||||||
@@ -471,14 +486,22 @@ function renderDashboard(json) {
|
|||||||
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
|
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
|
||||||
|
|
||||||
document.getElementById('kpiGrid').innerHTML = `
|
document.getElementById('kpiGrid').innerHTML = `
|
||||||
${kpiCard(total, '📋 Total turnos', '')}
|
${kpiCard(total, '📋 Turnos del día', '',
|
||||||
${kpiCard(atendidos,'✅ Atendidos', pct + '%')}
|
'Todos los turnos generados hoy, sin importar cómo terminaron.')}
|
||||||
${kpiCard(enEspera, '⏳ En espera', '')}
|
${kpiCard(atendidos, '✅ Atendidos', pct + ' % del día',
|
||||||
${kpiCard(ausentes, '🚫 Ausentes', '')}
|
'Ya terminaron su atención, más los que están siendo atendidos en este momento.')}
|
||||||
${kpiCard(cancelados,'❌ Cancelados', '')}
|
${kpiCard(enEspera, '⏳ Esperando', '',
|
||||||
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')}
|
'Todavía no los llaman, o ya salieron de recepción y esperan el puesto de toma.')}
|
||||||
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')}
|
${kpiCard(ausentes, '🚫 No se presentaron', '',
|
||||||
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total prom.', 'puerta a puerta')}
|
'Se les llamó y no llegaron. Recepción registra el motivo al marcarlos.')}
|
||||||
|
${kpiCard(cancelados, '❌ Cancelados', '',
|
||||||
|
'Turnos anulados antes de atenderlos.')}
|
||||||
|
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera para recepción', 'sacar el turno → lo llaman',
|
||||||
|
'Cuánto aguarda el paciente sentado desde que saca el turno hasta que recepción lo atiende.')}
|
||||||
|
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Tiempo en el puesto', 'toma de muestras',
|
||||||
|
'Lo que dura la toma. En protocolos prolongados (curvas) se cuenta hasta la primera toma: las horas de espera del examen no son trabajo del puesto.')}
|
||||||
|
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total en el laboratorio', 'llegada → salida',
|
||||||
|
'Todo el recorrido: espera + recepción + espera del puesto + toma. Por eso es mayor que la suma de las dos casillas anteriores.')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── Consentimientos ────────────────────────────────────
|
// ── Consentimientos ────────────────────────────────────
|
||||||
@@ -536,16 +559,51 @@ function renderDashboard(json) {
|
|||||||
secLug.style.display = 'none';
|
secLug.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tarjetas por persona ───────────────────────────────
|
||||||
|
renderPersonas('sectionRecepcionista', 'recepcionistaGrid', por_recepcionista, [
|
||||||
|
['Turnos', p => p.total],
|
||||||
|
['Atendidos', p => p.atendidos],
|
||||||
|
['Ausentes', p => p.ausentes],
|
||||||
|
['T. recepción prom.', p => p.tiempo_promedio_min !== null ? p.tiempo_promedio_min + ' min' : '—'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderPersonas('sectionBacteriologo', 'bacteriologoGrid', por_bacteriologo, [
|
||||||
|
['Turnos', p => p.total],
|
||||||
|
['Finalizados', p => p.finalizados],
|
||||||
|
['En servicio', p => p.en_servicio],
|
||||||
|
['T. servicio prom.', p => p.tiempo_promedio_min !== null ? p.tiempo_promedio_min + ' min' : '—'],
|
||||||
|
]);
|
||||||
|
|
||||||
// ── Tabla de turnos ────────────────────────────────────
|
// ── Tabla de turnos ────────────────────────────────────
|
||||||
_turnos = turnos;
|
_turnos = turnos;
|
||||||
renderTabla(turnos);
|
renderTabla(turnos);
|
||||||
}
|
}
|
||||||
|
|
||||||
function kpiCard(val, lbl, sub) {
|
// Tarjetas "quién atendió a cuántos". Reutiliza el grid de lugares para que
|
||||||
return `<div class="kpi-card">
|
// las tres secciones se vean iguales.
|
||||||
|
function renderPersonas(sectionId, gridId, filas, columnas) {
|
||||||
|
const sec = document.getElementById(sectionId);
|
||||||
|
if (!filas || !filas.length) { sec.style.display = 'none'; return; }
|
||||||
|
sec.style.display = '';
|
||||||
|
document.getElementById(gridId).innerHTML = filas.map(p => `
|
||||||
|
<div class="lugar-card">
|
||||||
|
<div class="lc-name"><i class="fas fa-user text-primary me-1"></i>${escHtml(p.nombre || '—')}</div>
|
||||||
|
${columnas.map(([lbl, val]) =>
|
||||||
|
`<div class="lc-row"><span>${lbl}</span><span>${escHtml(String(val(p) ?? '—'))}</span></div>`
|
||||||
|
).join('')}
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function kpiCard(val, lbl, sub, ayuda) {
|
||||||
|
// `ayuda` explica de dónde sale el número. Las etiquetas cortas se prestan
|
||||||
|
// para malentendidos —«servicio» no es lo mismo que «tiempo en el
|
||||||
|
// laboratorio»— y quien lee el tablero no tiene por qué adivinarlo.
|
||||||
|
const t = ayuda ? ` title="${escHtml(ayuda)}"` : '';
|
||||||
|
return `<div class="kpi-card"${t}>
|
||||||
<div class="kpi-val">${val !== null && val !== undefined ? escHtml(String(val)) : '—'}</div>
|
<div class="kpi-val">${val !== null && val !== undefined ? escHtml(String(val)) : '—'}</div>
|
||||||
<div class="kpi-lbl">${lbl}</div>
|
<div class="kpi-lbl">${lbl}</div>
|
||||||
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
|
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
|
||||||
|
${ayuda ? `<div class="kpi-ayuda">${escHtml(ayuda)}</div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -633,15 +633,21 @@ function getVozES() {
|
|||||||
}
|
}
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
|
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
|
||||||
// Keepalive: Chrome suspende speechSynthesis tras un rato sin uso.
|
// Keepalive con TRABAJO MUDO. El motor de voz (comprobado en macOS, y
|
||||||
// Solo debe correr con el sintetizador en reposo: pause() a mitad de una
|
// reportado también en Windows) se duerme tras un rato de ocio, y el primer
|
||||||
// frase la corta, y como el anuncio dura más de 10 segundos, antes lo
|
// speak() de ahí en adelante se ATASCA: speaking=true, cero audio, ni
|
||||||
// troceaba siempre.
|
// 'start' ni 'error' jamás. pause()/resume() no lo mantiene despierto —
|
||||||
|
// se verificó en vivo el 23/08: el primer llamado tras el ocio salía solo
|
||||||
|
// con el pito. Lo único que lo mantiene despierto es hablar de verdad:
|
||||||
|
// una letra a volumen cero cada 25 s, inaudible, con el motor en reposo.
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
if (!sonidoActivo) return;
|
||||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
|
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
|
||||||
window.speechSynthesis.pause();
|
const u = new SpeechSynthesisUtterance('a');
|
||||||
|
u.volume = 0; u.rate = 2;
|
||||||
window.speechSynthesis.resume();
|
window.speechSynthesis.resume();
|
||||||
}, 10000);
|
window.speechSynthesis.speak(u);
|
||||||
|
}, 25000);
|
||||||
}
|
}
|
||||||
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
|
// 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.
|
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
|
||||||
@@ -651,6 +657,58 @@ if ('speechSynthesis' in window) {
|
|||||||
// A nivel de archivo porque showAnnouncement() también lo necesita.
|
// A nivel de archivo porque showAnnouncement() también lo necesita.
|
||||||
const BEEP_MS = 700;
|
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 (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dice el llamado en voz alta.
|
* Dice el llamado en voz alta.
|
||||||
*
|
*
|
||||||
@@ -685,25 +743,72 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
|
|||||||
utt.lang = 'es-CO';
|
utt.lang = 'es-CO';
|
||||||
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
|
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;
|
let arranco = false;
|
||||||
utt.addEventListener('start', () => { arranco = true; });
|
utt.addEventListener('start', () => {
|
||||||
utt.addEventListener('end', avisar);
|
arranco = true;
|
||||||
utt.addEventListener('error', () => esReintento ? avisar() : decir(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
|
// 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
|
// navegador bloquea el audio, o la voz no sirve, no pasa absolutamente
|
||||||
// nada y 'error' tampoco llega. Solo se nota porque 'start' no ocurre.
|
// 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;
|
const margen = (esReintento ? 0 : BEEP_MS) + 2500;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (!vigente()) { soltar(); return; }
|
||||||
if (arranco) return;
|
if (arranco) return;
|
||||||
// Antes de darla por fallida hay que preguntarle al sintetizador:
|
// pending sin speaking = voz remota descargando: una prórroga de 6 s
|
||||||
// una voz remota tarda en arrancar porque se baja de internet, y
|
// y solo entonces se da por perdida. Cortarla de una fue el defecto
|
||||||
// cancelarla aquí era cortarle la frase y repetirla con otra voz.
|
// del entrecortado original.
|
||||||
// De ahí que a veces se oyera media frase y luego otra distinta.
|
if (window.speechSynthesis.pending && !window.speechSynthesis.speaking) {
|
||||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
|
setTimeout(() => { if (!vigente() || arranco) return; rendirse(); }, 6000);
|
||||||
if (esReintento) { avisar(); return; }
|
return;
|
||||||
window.speechSynthesis.cancel();
|
}
|
||||||
decir(true); // segunda oportunidad, con la voz del navegador
|
// speaking sin 'start' = ATASCO (finge hablar sin audio). Una locución
|
||||||
|
// que de verdad suena dispara 'start' de inmediato: romperlo.
|
||||||
|
rendirse();
|
||||||
}, margen);
|
}, margen);
|
||||||
|
|
||||||
// Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un
|
// Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un
|
||||||
@@ -721,8 +826,12 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
|
|||||||
const voz = getVozES();
|
const voz = getVozES();
|
||||||
if (voz) utt.voice = voz;
|
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);
|
window.speechSynthesis.speak(utt);
|
||||||
}, esReintento ? 0 : BEEP_MS);
|
}, esReintento ? 250 : BEEP_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
decir(false);
|
decir(false);
|
||||||
@@ -957,6 +1066,23 @@ cargarSnapshot();
|
|||||||
// 1 s en vez de 2: el llamado salía con hasta 2 s de retraso desde el clic
|
// 1 s en vez de 2: el llamado salía con hasta 2 s de retraso desde el clic
|
||||||
setInterval(cargarSnapshot, 1000);
|
setInterval(cargarSnapshot, 1000);
|
||||||
|
|
||||||
|
// ── Despertador SSE ──
|
||||||
|
// Esta pantalla corre en la ventana de un computador de uso mixto: puede
|
||||||
|
// quedar tapada o minimizada, y Chrome congela los relojes de las pestañas
|
||||||
|
// ocultas (~1 tic/min). El sondeo de arriba se paraliza y los llamados no
|
||||||
|
// suenan. Los eventos de red NO se congelan: el servidor emite cola_update en
|
||||||
|
// cada llamado y rellamado (sse_ping_at), y ese mensaje despierta la pantalla
|
||||||
|
// aunque esté de fondo. El sondeo queda como respaldo si el SSE se cae; si
|
||||||
|
// ambos fallan, el vigía de congelamiento lo deja anotado en la bitácora.
|
||||||
|
(function conectarSSE() {
|
||||||
|
if (!('EventSource' in window)) return;
|
||||||
|
try {
|
||||||
|
const es = new EventSource(BASE_API + 'sse_turno.php?area=recepcion');
|
||||||
|
es.addEventListener('cola_update', () => { try { cargarSnapshot(); } catch (_) {} });
|
||||||
|
// EventSource se reconecta solo tras un error; no hay nada que hacer aquí
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) cargarSnapshot(); });
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) cargarSnapshot(); });
|
||||||
window.addEventListener('focus', cargarSnapshot);
|
window.addEventListener('focus', cargarSnapshot);
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,14 @@ Layout::open('Historial de Turnos', 'fas fa-history');
|
|||||||
|
|
||||||
/* ── Documentos firmados ── */
|
/* ── Documentos firmados ── */
|
||||||
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
|
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
|
||||||
|
.traza { border-left:2px solid #e2e8f0; margin:2px 0 0 6px; padding-left:10px; }
|
||||||
|
.traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.78rem; }
|
||||||
|
.traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
|
||||||
|
.traza-ico { width:14px; text-align:center; margin-top:2px; font-size:.72rem; }
|
||||||
|
.traza-txt { flex:1; color:#334155; }
|
||||||
|
.traza-quien { color:#64748b; font-style:italic; }
|
||||||
|
.traza-extra { color:#b45309; font-size:.74rem; margin-top:1px; }
|
||||||
|
.traza-nota { color:#94a3b8; font-size:.7rem; margin-top:6px; font-style:italic; }
|
||||||
.docs-lbl { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
|
.docs-lbl { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
|
||||||
.doc-pill { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:99px;
|
.doc-pill { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:99px;
|
||||||
font-size:.73rem; font-weight:600; margin:2px 2px; text-decoration:none; transition:opacity .15s; }
|
font-size:.73rem; font-weight:600; margin:2px 2px; text-decoration:none; transition:opacity .15s; }
|
||||||
@@ -472,11 +480,19 @@ function renderTabla(turnos) {
|
|||||||
const bacterio = esc(t.atendido_lugar_nombre || '—');
|
const bacterio = esc(t.atendido_lugar_nombre || '—');
|
||||||
|
|
||||||
// Exámenes: badges con código
|
// Exámenes: badges con código
|
||||||
const examsHtml = (t.examenes && t.examenes.length)
|
// Solo el conteo: con 9,5 exámenes de promedio por turno, las etiquetas de
|
||||||
? t.examenes.map(e => `<span class="badge bg-success-subtle text-success me-1 mb-1" style="font-size:.65rem">${esc(e.codigo)}</span>`).join('')
|
// código llenaban cinco renglones y la tabla quedaba ilegible. Los nombres
|
||||||
|
// completos ya están en el detalle desplegable, así que no se pierde nada.
|
||||||
|
const nExam = (t.examenes && t.examenes.length) ? t.examenes.length : 0;
|
||||||
|
const examsHtml = nExam
|
||||||
|
? `<span class="badge bg-success-subtle text-success" style="font-size:.68rem"
|
||||||
|
title="${esc(t.examenes.map(e => e.nombre || e.codigo).join(' · '))}">
|
||||||
|
${nExam} examen${nExam === 1 ? '' : 'es'}
|
||||||
|
</span>`
|
||||||
: '<span class="text-muted small">—</span>';
|
: '<span class="text-muted small">—</span>';
|
||||||
|
|
||||||
return `<tr data-id="${t.id}">
|
return `<tr data-id="${t.id}" style="cursor:pointer"
|
||||||
|
onclick="filaDetalle(event, '${rowId}', ${t.id})">
|
||||||
<td class="text-nowrap text-muted small">${esc(fecha)}</td>
|
<td class="text-nowrap text-muted small">${esc(fecha)}</td>
|
||||||
<td><strong style="color:${priCls}">${esc(t.codigo)}</strong></td>
|
<td><strong style="color:${priCls}">${esc(t.codigo)}</strong></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -496,19 +512,19 @@ function renderTabla(turnos) {
|
|||||||
<div class="fw-semibold">${lugar}</div>
|
<div class="fw-semibold">${lugar}</div>
|
||||||
<div class="text-muted" style="font-size:.72rem"><i class="fas fa-microscope me-1 opacity-50"></i>${bacterio}</div>
|
<div class="text-muted" style="font-size:.72rem"><i class="fas fa-microscope me-1 opacity-50"></i>${bacterio}</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="max-width:140px">${examsHtml}</td>
|
<td class="text-nowrap">${examsHtml}</td>
|
||||||
<td><span class="eb ${cls}">${lbl}</span></td>
|
<td><span class="eb ${cls}">${lbl}</span></td>
|
||||||
<td class="text-nowrap">${espMin}</td>
|
<td class="text-nowrap">${espMin}</td>
|
||||||
<td class="text-nowrap">${srvMin}</td>
|
<td class="text-nowrap">${srvMin}</td>
|
||||||
<td class="text-nowrap">${totMin}</td>
|
<td class="text-nowrap">${totMin}</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
<button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
|
<button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
|
||||||
onclick="toggleDetalle('${rowId}', this, ${t.id})"
|
onclick="event.stopPropagation();toggleDetalle('${rowId}', this, ${t.id})"
|
||||||
title="Ver detalles">
|
title="Ver detalles">
|
||||||
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
|
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-warning py-0 px-2"
|
<button class="btn btn-sm btn-outline-warning py-0 px-2"
|
||||||
onclick="abrirModalEstado(${t.id}, '${t.estado}', '${nombre}')"
|
onclick="event.stopPropagation();abrirModalEstado(${t.id}, '${t.estado}', '${nombre}')"
|
||||||
title="Cambiar estado">
|
title="Cambiar estado">
|
||||||
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
|
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -594,12 +610,32 @@ function renderDetalle(t) {
|
|||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
${relHtml}${examHtml}${comHtml}
|
${relHtml}${examHtml}${comHtml}
|
||||||
|
<div class="docs-section" id="traza-${t.id}">
|
||||||
|
<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>
|
||||||
|
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
|
||||||
|
</div>
|
||||||
<div class="docs-section" id="docs-${t.id}">
|
<div class="docs-section" id="docs-${t.id}">
|
||||||
<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div>
|
<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div>
|
||||||
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
|
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clic en cualquier parte de la fila: abre o cierra su detalle.
|
||||||
|
*
|
||||||
|
* Se ignora si el usuario estaba seleccionando texto —copiar un documento o un
|
||||||
|
* nombre es frecuente aquí y sería molesto que la fila se abriera al soltar— y
|
||||||
|
* si el clic cayó sobre un enlace o un botón, que tienen lo suyo que hacer.
|
||||||
|
*/
|
||||||
|
function filaDetalle(ev, rowId, turnoId) {
|
||||||
|
if (ev.target.closest('button, a, input, select')) return;
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.toString().length > 0) return;
|
||||||
|
const fila = document.querySelector(`tr[data-id="${turnoId}"]`);
|
||||||
|
const btn = fila ? fila.querySelector('button[title="Ver detalles"]') : null;
|
||||||
|
if (btn) toggleDetalle(rowId, btn, turnoId);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDetalle(rowId, btn, turnoId) {
|
function toggleDetalle(rowId, btn, turnoId) {
|
||||||
const row = document.getElementById(rowId);
|
const row = document.getElementById(rowId);
|
||||||
const icon = btn.querySelector('i');
|
const icon = btn.querySelector('i');
|
||||||
@@ -614,11 +650,79 @@ function toggleDetalle(rowId, btn, turnoId) {
|
|||||||
docsEl.dataset.loaded = '1';
|
docsEl.dataset.loaded = '1';
|
||||||
cargarDocumentosTurno(turnoId, docsEl);
|
cargarDocumentosTurno(turnoId, docsEl);
|
||||||
}
|
}
|
||||||
|
const trazaEl = document.getElementById(`traza-${turnoId}`);
|
||||||
|
if (trazaEl && !trazaEl.dataset.loaded) {
|
||||||
|
trazaEl.dataset.loaded = '1';
|
||||||
|
cargarTrazaTurno(turnoId, trazaEl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_URL_ROOT = '<?= BASE_URL ?>';
|
const BASE_URL_ROOT = '<?= BASE_URL ?>';
|
||||||
|
|
||||||
|
// ── Bitácora del turno ────────────────────────────────────────
|
||||||
|
// Junta en una sola línea de tiempo lo que antes estaba repartido: el recorrido
|
||||||
|
// del turno, los documentos, los comentarios del personal, las muestras y las
|
||||||
|
// acciones administrativas auditadas.
|
||||||
|
const TRAZA_ICONO = {
|
||||||
|
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
|
||||||
|
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
|
||||||
|
doc_ok:'fa-file-signature', comentario:'fa-comment', alerta:'fa-triangle-exclamation',
|
||||||
|
admin:'fa-user-shield',
|
||||||
|
};
|
||||||
|
const TRAZA_COLOR = {
|
||||||
|
alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb',
|
||||||
|
fin:'#0f766e', admin:'#7c3aed',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cargarTrazaTurno(turnoId, container) {
|
||||||
|
const titulo = '<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}get_traza_turno.php?turno_id=${turnoId}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.ok) { container.innerHTML = titulo + '<span class="docs-empty">No se pudo cargar</span>'; return; }
|
||||||
|
|
||||||
|
const ev = json.eventos || [];
|
||||||
|
if (!ev.length) { container.innerHTML = titulo + '<span class="docs-empty">Sin registros</span>'; return; }
|
||||||
|
|
||||||
|
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
|
||||||
|
const filas = ev.map(e => {
|
||||||
|
const color = TRAZA_COLOR[e.tipo] || '#94a3b8';
|
||||||
|
const ico = TRAZA_ICONO[e.tipo] || 'fa-circle';
|
||||||
|
const quien = e.quien ? `<span class="traza-quien">${esc(e.quien)}</span>` : '';
|
||||||
|
const extra = e.extra ? `<div class="traza-extra">${esc(e.extra)}</div>` : '';
|
||||||
|
return `<div class="traza-fila">
|
||||||
|
<span class="traza-hora">${hora(e.cuando)}</span>
|
||||||
|
<i class="fas ${ico} traza-ico" style="color:${color}"></i>
|
||||||
|
<div class="traza-txt"><span>${esc(e.texto)}</span> ${quien}${extra}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// El chat no se vuelca aquí: son datos personales del paciente y esta es
|
||||||
|
// una pantalla de consulta. Se indica que existe y se enlaza.
|
||||||
|
const chat = json.chat
|
||||||
|
? `<div class="traza-fila">
|
||||||
|
<span class="traza-hora"></span>
|
||||||
|
<i class="fab fa-whatsapp traza-ico" style="color:#25d366"></i>
|
||||||
|
<div class="traza-txt">
|
||||||
|
<a href="erp.php?m=turnero&v=chat&user_id=${json.chat.user_id}" target="_blank">
|
||||||
|
${json.chat.mensajes} mensaje(s) de WhatsApp ese día — ver conversación
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
// Se dice qué NO quedó registrado, para que la ausencia de un evento no
|
||||||
|
// se lea como prueba de que no ocurrió.
|
||||||
|
const nota = (json.sinRastro && json.sinRastro.length)
|
||||||
|
? `<div class="traza-nota">El sistema aún no deja rastro de: ${json.sinRastro.map(esc).join(' · ')}.</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
container.innerHTML = titulo + `<div class="traza">${filas}${chat}</div>` + nota;
|
||||||
|
} catch (_) {
|
||||||
|
container.innerHTML = titulo + '<span class="docs-empty">Error al cargar</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function cargarDocumentosTurno(turnoId, container) {
|
async function cargarDocumentosTurno(turnoId, container) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
|
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
|
||||||
|
|||||||
@@ -1002,6 +1002,14 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
|
|||||||
<label class="mpac-lbl">EPS</label>
|
<label class="mpac-lbl">EPS</label>
|
||||||
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-eps">
|
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-eps">
|
||||||
|
|
||||||
|
<label class="mpac-lbl">Sexo</label>
|
||||||
|
<select class="form-select form-select-sm mb-2" id="mpac-in-genero">
|
||||||
|
<option value="">— Sin registrar —</option>
|
||||||
|
<option value="M">Masculino</option>
|
||||||
|
<option value="F">Femenino</option>
|
||||||
|
<option value="O">Otro</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- Datos de identidad: requieren un paso deliberado -->
|
<!-- Datos de identidad: requieren un paso deliberado -->
|
||||||
<div id="mpac-identidad-lock" class="mpac-lock">
|
<div id="mpac-identidad-lock" class="mpac-lock">
|
||||||
<div class="mpac-lock-txt">
|
<div class="mpac-lock-txt">
|
||||||
@@ -2521,6 +2529,7 @@ function mpacEditar(abrir) {
|
|||||||
document.getElementById('mpac-in-tel').value = p.telefono || p.celular || '';
|
document.getElementById('mpac-in-tel').value = p.telefono || p.celular || '';
|
||||||
document.getElementById('mpac-in-dir').value = p.direccion || '';
|
document.getElementById('mpac-in-dir').value = p.direccion || '';
|
||||||
document.getElementById('mpac-in-eps').value = p.eps || '';
|
document.getElementById('mpac-in-eps').value = p.eps || '';
|
||||||
|
document.getElementById('mpac-in-genero').value = p.genero || '';
|
||||||
document.getElementById('mpac-in-nombre').value = p.nombre_completo || '';
|
document.getElementById('mpac-in-nombre').value = p.nombre_completo || '';
|
||||||
document.getElementById('mpac-in-tipodoc').value = p.tipo_documento || 'CC';
|
document.getElementById('mpac-in-tipodoc').value = p.tipo_documento || 'CC';
|
||||||
document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || '';
|
document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || '';
|
||||||
@@ -2557,6 +2566,7 @@ async function mpacGuardarFicha() {
|
|||||||
telefono: val('mpac-in-tel'),
|
telefono: val('mpac-in-tel'),
|
||||||
direccion: val('mpac-in-dir'),
|
direccion: val('mpac-in-dir'),
|
||||||
eps: val('mpac-in-eps'),
|
eps: val('mpac-in-eps'),
|
||||||
|
genero: val('mpac-in-genero'),
|
||||||
};
|
};
|
||||||
// Los datos de identidad solo se envían si se desbloquearon
|
// Los datos de identidad solo se envían si se desbloquearon
|
||||||
if (!document.getElementById('mpac-identidad').classList.contains('d-none')) {
|
if (!document.getElementById('mpac-identidad').classList.contains('d-none')) {
|
||||||
|
|||||||
@@ -1553,7 +1553,7 @@ function abrirFicha(turno) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} else if (turno.paciente_nombre && /^\d{5,15}$/.test(turno.paciente_nombre.trim())) {
|
} else if (turno.paciente_nombre && /^\d{5,15}$/.test(turno.paciente_nombre.trim())) {
|
||||||
// El kiosko capturó una cédula como nombre — consultar RIPS de inmediato
|
// El kiosko capturó una cédula como nombre — consultar RIPS de inmediato
|
||||||
consultarExamenesRips(turno.paciente_nombre.trim());
|
iniciarSondeoRips(turno.paciente_nombre.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargar consentimientos del desk actual para este turno
|
// Cargar consentimientos del desk actual para este turno
|
||||||
@@ -1673,7 +1673,7 @@ function seleccionarPaciente(pac) {
|
|||||||
|
|
||||||
// Consultar exámenes recientes en RIPS (últimos 5 min)
|
// Consultar exámenes recientes en RIPS (últimos 5 min)
|
||||||
const cedula = (pac.numero_documento || pac.documento || '').toString().trim();
|
const cedula = (pac.numero_documento || pac.documento || '').toString().trim();
|
||||||
if (cedula) consultarExamenesRips(cedula);
|
if (cedula) iniciarSondeoRips(cedula);
|
||||||
filtrarExamenesPorGenero();
|
filtrarExamenesPorGenero();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1696,6 +1696,31 @@ function filtrarExamenesPorGenero() {
|
|||||||
// ── Exámenes desde RIPS ───────────────────────────────────────
|
// ── Exámenes desde RIPS ───────────────────────────────────────
|
||||||
let _ripsData = null;
|
let _ripsData = null;
|
||||||
|
|
||||||
|
// ── Sondeo RIPS ──
|
||||||
|
// La facturación en el sistema del laboratorio ocurre MIENTRAS la
|
||||||
|
// recepcionista atiende, así que consultar una sola vez al vincular al
|
||||||
|
// paciente casi siempre llega temprano: en 7 días, 547 de 549 registros RIPS
|
||||||
|
// aparecieron DESPUÉS de esa única consulta, y nadie volvía a preguntar. Eso
|
||||||
|
// es lo que se percibía como "RIPS se demora". Se sondea cada 10 s hasta 10
|
||||||
|
// minutos, y se detiene al encontrar, al limpiar la ficha o al crear el turno.
|
||||||
|
let _ripsSondeoId = null;
|
||||||
|
let _ripsSondeoQuedan = 0;
|
||||||
|
|
||||||
|
function iniciarSondeoRips(cedula) {
|
||||||
|
detenerSondeoRips();
|
||||||
|
if (!cedula) return;
|
||||||
|
_ripsSondeoQuedan = 60; // 60 intentos × 10 s = 10 min
|
||||||
|
consultarExamenesRips(cedula); // el primero, de inmediato
|
||||||
|
_ripsSondeoId = setInterval(() => {
|
||||||
|
if (--_ripsSondeoQuedan <= 0 || _ripsData) { detenerSondeoRips(); return; }
|
||||||
|
consultarExamenesRips(cedula);
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detenerSondeoRips() {
|
||||||
|
if (_ripsSondeoId) { clearInterval(_ripsSondeoId); _ripsSondeoId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
async function consultarExamenesRips(cedula) {
|
async function consultarExamenesRips(cedula) {
|
||||||
_ripsData = null;
|
_ripsData = null;
|
||||||
document.getElementById('banner-rips').classList.add('d-none');
|
document.getElementById('banner-rips').classList.add('d-none');
|
||||||
@@ -1704,6 +1729,15 @@ async function consultarExamenesRips(cedula) {
|
|||||||
const r = await fetch(`${BASE_WA}api/lab/get_examenes_rips.php?cedula=${encodeURIComponent(cedula)}`);
|
const r = await fetch(`${BASE_WA}api/lab/get_examenes_rips.php?cedula=${encodeURIComponent(cedula)}`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!d.ok || !d.encontrados?.length) return;
|
if (!d.ok || !d.encontrados?.length) return;
|
||||||
|
|
||||||
|
// La respuesta pudo llegar DESPUÉS de que la recepcionista cambió de
|
||||||
|
// ficha: sin esta comprobación, los exámenes de un paciente se
|
||||||
|
// cargarían en la ficha del siguiente. Si la cédula ya no corresponde
|
||||||
|
// a quien está en pantalla, la respuesta se descarta.
|
||||||
|
const cedulaEnPantalla = (pacienteActivo?.numero_documento
|
||||||
|
|| pacienteActivo?.documento
|
||||||
|
|| turnoActivo?.paciente_nombre || '').toString().trim();
|
||||||
|
if (cedulaEnPantalla !== cedula) { detenerSondeoRips(); return; }
|
||||||
_ripsData = d;
|
_ripsData = d;
|
||||||
const hora = d.hora ? ' (' + String(d.hora).slice(0, 5) + ')' : '';
|
const hora = d.hora ? ' (' + String(d.hora).slice(0, 5) + ')' : '';
|
||||||
const warn = document.getElementById('banner-rips-warn');
|
const warn = document.getElementById('banner-rips-warn');
|
||||||
@@ -1715,7 +1749,9 @@ async function consultarExamenesRips(cedula) {
|
|||||||
}
|
}
|
||||||
// Si los exámenes vienen del cache local (enviados por RIPS scheduler) se cargan
|
// Si los exámenes vienen del cache local (enviados por RIPS scheduler) se cargan
|
||||||
// automáticamente. Si vienen del pull en vivo se muestra el banner para confirmación.
|
// automáticamente. Si vienen del pull en vivo se muestra el banner para confirmación.
|
||||||
if (d.fuente === 'cache') {
|
detenerSondeoRips();
|
||||||
|
const yaHayManuales = examTS && examTS.items && examTS.items.length > 0;
|
||||||
|
if (d.fuente === 'cache' && !yaHayManuales) {
|
||||||
await cargarExamenesRips();
|
await cargarExamenesRips();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('banner-rips-txt').textContent =
|
document.getElementById('banner-rips-txt').textContent =
|
||||||
@@ -1760,6 +1796,7 @@ async function cargarExamenesRips() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function descartarRips() {
|
function descartarRips() {
|
||||||
|
detenerSondeoRips();
|
||||||
_ripsData = null;
|
_ripsData = null;
|
||||||
document.getElementById('banner-rips').classList.add('d-none');
|
document.getElementById('banner-rips').classList.add('d-none');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
@@ -696,9 +696,14 @@ function _addExamWizardHtml(string $cid): string {
|
|||||||
.campo-linked { display: flex; gap: 16px; padding: 6px 0;
|
.campo-linked { display: flex; gap: 16px; padding: 6px 0;
|
||||||
border-bottom: 1px solid #f0f0f0; }
|
border-bottom: 1px solid #f0f0f0; }
|
||||||
.campo-linked-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; }
|
.campo-linked-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; }
|
||||||
.lnk-corregir { background:none; border:none; color:#94a3b8; cursor:pointer;
|
/* Antes era solo un lápiz gris de 11px: estaba, pero nadie lo encontraba.
|
||||||
padding:0 4px; font-size:11px; flex-shrink:0; }
|
Lleva texto y contorno para que se lea como lo que es, un botón. */
|
||||||
.lnk-corregir:hover { color:#1565c0; }
|
.lnk-corregir { background:none; border:none; border-radius:4px;
|
||||||
|
color:#1565c0; cursor:pointer; padding:1px 5px; font-size:10px;
|
||||||
|
font-weight:600; white-space:nowrap; flex-shrink:0;
|
||||||
|
display:inline-flex; align-items:center; gap:3px;
|
||||||
|
text-decoration:underline; }
|
||||||
|
.lnk-corregir:hover { background:#1565c0; border-color:#1565c0; color:#fff; }
|
||||||
.campo-linked-valor { flex: 1; font-size: 13px; font-weight: 600;
|
.campo-linked-valor { flex: 1; font-size: 13px; font-weight: 600;
|
||||||
color: #1565c0; }
|
color: #1565c0; }
|
||||||
|
|
||||||
@@ -1289,8 +1294,8 @@ function _addExamWizardHtml(string $cid): string {
|
|||||||
<div class="campo-linked-valor"><?= esc2($lval) ?></div>
|
<div class="campo-linked-valor"><?= esc2($lval) ?></div>
|
||||||
<?php if ($modoTurnero && $embebido && $modoEditar && !$_lnkBtnPuesto): $_lnkBtnPuesto = true; ?>
|
<?php if ($modoTurnero && $embebido && $modoEditar && !$_lnkBtnPuesto): $_lnkBtnPuesto = true; ?>
|
||||||
<button type="button" class="lnk-corregir no-print" onclick="pedirCorregirPaciente()"
|
<button type="button" class="lnk-corregir no-print" onclick="pedirCorregirPaciente()"
|
||||||
title="Corregir los datos del paciente">
|
title="Abrir la ficha del paciente para corregir sus datos">
|
||||||
<i class="fas fa-pen"></i>
|
<i class="fas fa-pen" style="font-size:9px"></i>Corregir
|
||||||
</button>
|
</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -2302,6 +2307,8 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var datos = json.datos_respuestas || {};
|
var datos = json.datos_respuestas || {};
|
||||||
|
var tocados = [], llenos = 0;
|
||||||
|
|
||||||
document.querySelectorAll('[name]').forEach(function(el) {
|
document.querySelectorAll('[name]').forEach(function(el) {
|
||||||
var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0, -2) : raw;
|
var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0, -2) : raw;
|
||||||
if (!(name in datos)) return;
|
if (!(name in datos)) return;
|
||||||
@@ -2314,9 +2321,25 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
|
|||||||
} else {
|
} else {
|
||||||
el.value = val;
|
el.value = val;
|
||||||
}
|
}
|
||||||
|
tocados.push(el);
|
||||||
|
llenos++;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Asignar el valor a mano no dispara ningún evento, y las secciones
|
||||||
|
// condicionales del formulario —tipo de dolor, tipo de cáncer, grupo
|
||||||
|
// sanguíneo— se despliegan escuchando 'change' e 'input'. Sin esto los
|
||||||
|
// datos entraban pero las secciones seguían ocultas, así que parecía
|
||||||
|
// que el botón no hacía nada.
|
||||||
|
tocados.forEach(function(el) {
|
||||||
|
['input', 'change'].forEach(function(tipo) {
|
||||||
|
el.dispatchEvent(new Event(tipo, { bubbles: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
msg.style.display = '';
|
msg.style.display = '';
|
||||||
msg.textContent = 'Datos cargados de la visita ' + (json.turno_codigo || '') + '. Revise y firme.';
|
msg.textContent = llenos
|
||||||
|
? 'Se cargaron ' + llenos + ' dato(s) de la visita ' + (json.turno_codigo || '') + '. Revise y firme.'
|
||||||
|
: 'La visita ' + (json.turno_codigo || '') + ' no tiene datos que correspondan a este formulario.';
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user