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