Vincular turnos por muestra pendiente y precargar Datos Toma de Muestras

El turno original no se modifica (queda finalizado igual que antes) — solo
se agrega trazabilidad: turnero_muestras.recibida_en_turno_id registra en
qué turno se completó una muestra que había quedado pendiente en otra
visita. Historial, bandeja y lugar.php ahora muestran un chip/botón "Turno
vinculado" en ambos sentidos (origen ⇄ seguimiento) con acceso directo.

Además, al abrir un formulario "Datos Toma de Muestras" pendiente para una
visita de seguimiento, aparece un botón para cargar las respuestas del
último formulario firmado de ese mismo paciente (sin firma ni identidad
del profesional anterior) — evita reescribir la historia clínica; la
firma y fecha siguen siendo las de hoy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-03 13:10:59 -05:00
co-authored by Claude Sonnet 4.6
parent 8e1f46904c
commit 538ee8ad25
8 changed files with 233 additions and 6 deletions
+27
View File
@@ -81,12 +81,39 @@ if (isset($_GET['turno_id'])) {
$stmtCom->execute([$tid]);
$comentarios = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
// Turnos vinculados por muestras pendientes completadas en otra visita (ambos sentidos)
$relacionados = [];
$stmtRelFwd = $pdo->prepare("
SELECT t2.id AS rel_id, t2.codigo AS rel_codigo, s2.fecha AS rel_fecha
FROM turnero_muestras tm
JOIN turnero_turnos t2 ON t2.id = tm.recibida_en_turno_id
JOIN turnero_sesiones s2 ON s2.id = t2.sesion_id
WHERE tm.solicitud_id = ? AND tm.recibida_en_turno_id IS NOT NULL
");
$stmtRelFwd->execute([$solId]);
foreach ($stmtRelFwd->fetchAll(PDO::FETCH_ASSOC) as $r) {
$relacionados[] = ['turno_id' => (int)$r['rel_id'], 'codigo' => $r['rel_codigo'], 'fecha' => $r['rel_fecha'], 'direccion' => 'seguimiento'];
}
$stmtRelBack = $pdo->prepare("
SELECT t1.id AS rel_id, t1.codigo AS rel_codigo, s1.fecha AS rel_fecha
FROM turnero_muestras tm
JOIN turnero_solicitudes ts1 ON ts1.id = tm.solicitud_id
JOIN turnero_turnos t1 ON t1.id = ts1.turno_id
JOIN turnero_sesiones s1 ON s1.id = t1.sesion_id
WHERE tm.recibida_en_turno_id = ?
");
$stmtRelBack->execute([$tid]);
foreach ($stmtRelBack->fetchAll(PDO::FETCH_ASSOC) as $r) {
$relacionados[] = ['turno_id' => (int)$r['rel_id'], 'codigo' => $r['rel_codigo'], 'fecha' => $r['rel_fecha'], 'direccion' => 'origen'];
}
jsonOk([
'turno' => $data,
'examenes_por_cat'=> $examenesPorCat,
'muestras' => $muestras,
'consentimientos' => $consentimientos,
'comentarios' => $comentarios,
'relacionados' => $relacionados,
]);
}
@@ -0,0 +1,53 @@
<?php
/**
* GET ?paciente_id=X&formulario_id=Y&exclude_turno_id=Z
* Devuelve las respuestas del último formulario firmado de ese tipo para el paciente
* (ej. "Datos Toma de Muestras"), para precargarlo en una visita de seguimiento
* sin obligar a re-digitar la historia clínica. No modifica el registro original.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
requireTurnero();
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
$formularioId = (int)($_GET['formulario_id'] ?? 0);
$excludeTurno = (int)($_GET['exclude_turno_id'] ?? 0);
if (!$pacienteId) jsonError('paciente_id requerido.');
if (!$formularioId) jsonError('formulario_id requerido.');
$pdo = db();
$stmt = $pdo->prepare(
"SELECT tc.datos_respuestas, tc.firmado_at, t.codigo AS turno_codigo
FROM turnero_consentimientos tc
JOIN turnero_turnos t ON t.id = tc.turno_id
LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id
WHERE tc.formulario_id = ?
AND tc.estado = 'firmado'
AND COALESCE(ts.paciente_id, t.paciente_id) = ?
AND t.id != ?
ORDER BY tc.firmado_at DESC
LIMIT 1"
);
$stmt->execute([$formularioId, $pacienteId, $excludeTurno]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
jsonOk(['encontrado' => false]);
}
$datos = json_decode($row['datos_respuestas'] ?? '{}', true) ?: [];
// No traer firmas ni identidad del profesional anterior: cada visita se firma de nuevo
foreach (array_keys($datos) as $k) {
if (str_contains($k, 'firma') || $k === '_pro_nombre' || $k === '_pro_cedula') {
unset($datos[$k]);
}
}
jsonOk([
'encontrado' => true,
'datos_respuestas'=> $datos,
'firmado_at' => $row['firmado_at'],
'turno_codigo' => $row['turno_codigo'],
]);
+37
View File
@@ -144,6 +144,7 @@ $turnoIds = array_column($turnos, 'id');
$examenes = [];
$consentimientos = [];
$comentarios = [];
$relacionados = [];
if ($turnoIds) {
$ph = implode(',', array_fill(0, count($turnoIds), '?'));
@@ -194,6 +195,41 @@ if ($turnoIds) {
}
}
// Turnos vinculados por muestras pendientes completadas en otra visita — batch (ambos sentidos)
// Sentido "seguimiento": este turno tenía una muestra pendiente que se completó en OTRO turno
$stmtRelFwd = $pdo->prepare(
"SELECT DISTINCT ts.turno_id AS turno_id, t2.id AS rel_id, t2.codigo AS rel_codigo, s2.fecha AS rel_fecha
FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
JOIN turnero_turnos t2 ON t2.id = tm.recibida_en_turno_id
JOIN turnero_sesiones s2 ON s2.id = t2.sesion_id
WHERE ts.turno_id IN ($ph) AND tm.recibida_en_turno_id IS NOT NULL"
);
$stmtRelFwd->execute($turnoIds);
foreach ($stmtRelFwd->fetchAll(PDO::FETCH_ASSOC) as $row) {
$tid = (int)$row['turno_id'];
$relacionados[$tid][] = [
'turno_id' => (int)$row['rel_id'], 'codigo' => $row['rel_codigo'],
'fecha' => $row['rel_fecha'], 'direccion' => 'seguimiento',
];
}
// Sentido "origen": este turno completó una muestra pendiente de OTRO turno anterior
$stmtRelBack = $pdo->prepare(
"SELECT DISTINCT tm.recibida_en_turno_id AS turno_id, t1.id AS rel_id, t1.codigo AS rel_codigo, s1.fecha AS rel_fecha
FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
JOIN turnero_turnos t1 ON t1.id = ts.turno_id
JOIN turnero_sesiones s1 ON s1.id = t1.sesion_id
WHERE tm.recibida_en_turno_id IN ($ph)"
);
$stmtRelBack->execute($turnoIds);
foreach ($stmtRelBack->fetchAll(PDO::FETCH_ASSOC) as $row) {
$tid = (int)$row['turno_id'];
$relacionados[$tid][] = [
'turno_id' => (int)$row['rel_id'], 'codigo' => $row['rel_codigo'],
'fecha' => $row['rel_fecha'], 'direccion' => 'origen',
];
}
}
foreach ($turnos as &$turno) {
@@ -201,6 +237,7 @@ foreach ($turnos as &$turno) {
$turno['examenes'] = $examenes[$tid] ?? [];
$turno['consentimientos'] = $consentimientos[$tid] ?? [];
$turno['comentarios'] = $comentarios[$tid] ?? [];
$turno['relacionados'] = $relacionados[$tid] ?? [];
}
// ── Resumen por estado (del rango filtrado) ───────────────────
+12 -4
View File
@@ -8,6 +8,8 @@
* muestra_id int requerido
* estado string requerido 'recibida' | 'rechazada' | 'pendiente'
* motivo_rechazo string opcional requerido si estado = 'rechazada'
* turno_id int opcional turno activo desde el que se recibe (para trazabilidad
* cuando la muestra es de una visita anterior)
*/
require_once __DIR__ . '/_helpers.php';
@@ -18,6 +20,7 @@ $datos = inputJson();
$muestraId = isset($datos['muestra_id']) ? (int)$datos['muestra_id'] : 0;
$estado = trim($datos['estado'] ?? '');
$motivo = isset($datos['motivo_rechazo']) ? trim($datos['motivo_rechazo']) : null;
$turnoId = isset($datos['turno_id']) ? (int)$datos['turno_id'] : 0;
if ($muestraId <= 0) jsonError('muestra_id inválido.');
if (!in_array($estado, ['recibida', 'rechazada', 'pendiente'], true)) {
@@ -29,8 +32,9 @@ $pdo = db();
// Verificar que la muestra existe
$stmt = $pdo->prepare(
"SELECT tm.id, tm.solicitud_id, tm.estado
"SELECT tm.id, tm.solicitud_id, tm.estado, ts.turno_id AS turno_origen_id
FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
WHERE tm.id = ?"
);
$stmt->execute([$muestraId]);
@@ -38,6 +42,10 @@ $muestra = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$muestra) jsonError('Muestra no encontrada.', 404);
// Solo registrar "recibida en otro turno" cuando de verdad es una visita distinta
// a la que generó la muestra (no tocar la solicitud/turno original).
$recibidaEnTurno = ($turnoId && $turnoId !== (int)$muestra['turno_origen_id']) ? $turnoId : null;
// Construir campos a actualizar
$ahora = date('Y-m-d H:i:s');
$adminI = adminId();
@@ -46,9 +54,9 @@ if ($estado === 'recibida') {
$pdo->prepare(
"UPDATE turnero_muestras
SET estado = 'recibida', recibida_por = ?, recibida_at = ?,
motivo_rechazo = NULL
recibida_en_turno_id = ?, motivo_rechazo = NULL
WHERE id = ?"
)->execute([$adminI, $ahora, $muestraId]);
)->execute([$adminI, $ahora, $recibidaEnTurno, $muestraId]);
} elseif ($estado === 'rechazada') {
$pdo->prepare(
"UPDATE turnero_muestras
@@ -61,7 +69,7 @@ if ($estado === 'recibida') {
$pdo->prepare(
"UPDATE turnero_muestras
SET estado = 'pendiente', recibida_por = NULL,
recibida_at = NULL, motivo_rechazo = NULL
recibida_at = NULL, recibida_en_turno_id = NULL, motivo_rechazo = NULL
WHERE id = ?"
)->execute([$muestraId]);
}
+16 -1
View File
@@ -642,6 +642,21 @@ function _renderDetalle(d) {
}
secM += `</div>`;
// Turno vinculado (muestra pendiente completada en otra visita, o viceversa)
let secRel = '';
if (d.relacionados?.length) {
secRel = `<div class="bnd-sec"><div class="bnd-sec-title"><i class="fas fa-link"></i> Turno vinculado</div>`;
d.relacionados.forEach(r => {
const txt = r.direccion === 'seguimiento'
? `Muestra completada en ${r.codigo}`
: `Completa muestra pendiente de ${r.codigo}`;
secRel += `<button class="bnd-btn" style="margin-top:4px" onclick="_bndVerDetalle(${r.turno_id})">
<i class="fas fa-arrow-right"></i> ${txt}
</button>`;
});
secRel += `</div>`;
}
// Consentimientos
let secC = `<div class="bnd-sec"><div class="bnd-sec-title"><i class="fas fa-file-signature"></i> Consentimientos</div>`;
if (!d.consentimientos?.length) {
@@ -687,7 +702,7 @@ 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 + secC + secCom;
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom;
}
// ── Marcar visto ──────────────────────────────────────────────
+27 -1
View File
@@ -345,6 +345,15 @@ function buscar(pageNum) {
cargarPagina();
}
function irATurno(codigo, fecha) {
document.getElementById('fDesde').value = fecha || hoy();
document.getElementById('fHasta').value = fecha || hoy();
document.getElementById('fQ').value = codigo;
document.getElementById('fEstado').value = '';
buscar();
window.scrollTo({top:0, behavior:'smooth'});
}
async function cargarPagina() {
mostrarSpinner();
const params = new URLSearchParams({
@@ -524,6 +533,23 @@ function renderDetalle(t) {
{ lbl:'Atendió muestras', val: esc(t.atendido_lugar_nombre || '—') },
{ lbl:'Notas', val: esc(t.notas || '—') },
];
// Turnos vinculados (muestra pendiente completada en otra visita, o viceversa)
let relHtml = '';
if (t.relacionados && t.relacionados.length) {
relHtml = `<div class="detail-extra">
<div class="d-lbl"><i class="fas fa-link me-1 text-primary"></i>Turno vinculado</div>
<div class="d-val">${t.relacionados.map(r => {
const txt = r.direccion === 'seguimiento'
? `Muestra completada en ${esc(r.codigo)}`
: `Completa muestra pendiente de ${esc(r.codigo)}`;
return `<button class="btn btn-sm btn-outline-primary py-0 px-2 mb-1 me-1"
style="font-size:.72rem" onclick="irATurno('${esc(r.codigo)}','${r.fecha}')">
<i class="fas fa-arrow-right me-1"></i>${txt}
</button>`;
}).join('')}</div>
</div>`;
}
// Exámenes
let examHtml = '';
if (t.examenes && t.examenes.length) {
@@ -555,7 +581,7 @@ function renderDetalle(t) {
<div class="d-val">${it.val}</div>
</div>`).join('')}
</div>
${examHtml}${comHtml}
${relHtml}${examHtml}${comHtml}
<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>
+1
View File
@@ -2550,6 +2550,7 @@ async function marcarMuestra(muestraId, estado, motivo = null) {
try {
const body = { muestra_id: muestraId, estado };
if (motivo) body.motivo_rechazo = motivo;
if (turnoActivo?.id) body.turno_id = turnoActivo.id;
const res = await fetch(API + 'update_muestra_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+60
View File
@@ -34,6 +34,7 @@ if ($modoTurnero) {
tc.siguiente_toma_at, tc.toma_inicio_at,
f.nombre AS form_nombre, f.categoria, f.descripcion AS form_descripcion,
f.esquema, f.es_toma_progresiva, f.solo_profesional, f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina,
p.id AS paciente_id,
p.nombre_completo AS paciente_nombre,
p.numero_documento, p.tipo_documento,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
@@ -986,6 +987,15 @@ function _addExamWizardHtml(string $cid): string {
<i class="fas fa-wpforms me-1"></i>
<?= $modoEditar ? 'Formulario de consentimiento' : 'Respuestas del formulario' ?>
</div>
<?php if ($modoTurnero && $modoEditar && !empty($tcRow['paciente_id'])): ?>
<button type="button" id="btnCargarAnterior" class="btn btn-outline-primary btn-sm no-print mb-3"
data-paciente="<?= (int)$tcRow['paciente_id'] ?>"
data-formulario="<?= (int)$tcRow['formulario_id'] ?>"
data-turno="<?= (int)$tcRow['turno_id'] ?>">
<i class="fas fa-history me-1"></i>Cargar datos de la visita anterior
</button>
<div id="cargarAnteriorMsg" class="small text-muted mb-2" style="display:none"></div>
<?php endif; ?>
<?php
$paciente = $datosPrefilled['__paciente'] ?? [];
@@ -2193,6 +2203,56 @@ document.addEventListener('DOMContentLoaded', function() {
window._fpwPreFirma = <?= json_encode($firmaProfPreguardada) ?>;
window._profNombre = <?= json_encode($profNombre) ?>;
window._profDocumento = <?= json_encode($profDocumento) ?>;
// ── Cargar datos de la visita anterior (mismo formulario, mismo paciente) ──
(function() {
var btn = document.getElementById('btnCargarAnterior');
if (!btn) return;
btn.addEventListener('click', function() {
var base = window.location.href.split('/ver_formulario_enviado.php')[0];
var msg = document.getElementById('cargarAnteriorMsg');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Buscando…';
var params = new URLSearchParams({
paciente_id: btn.dataset.paciente,
formulario_id: btn.dataset.formulario,
exclude_turno_id: btn.dataset.turno,
});
fetch(base + '/modules/turnero/api/get_formulario_anterior.php?' + params)
.then(function(r) { return r.json(); })
.then(function(json) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-history me-1"></i>Cargar datos de la visita anterior';
if (!json.ok || !json.encontrado) {
msg.style.display = '';
msg.textContent = 'No hay una visita anterior con este formulario firmado.';
return;
}
var datos = json.datos_respuestas || {};
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;
var val = datos[name];
if (el.type === 'checkbox') {
var arr = Array.isArray(val) ? val : [val];
el.checked = arr.includes(el.value);
} else if (el.type === 'radio') {
el.checked = (el.value === val);
} else {
el.value = val;
}
});
msg.style.display = '';
msg.textContent = 'Datos cargados de la visita ' + (json.turno_codigo || '') + '. Revise y firme.';
})
.catch(function() {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-history me-1"></i>Cargar datos de la visita anterior';
msg.style.display = '';
msg.textContent = 'Error de conexión.';
});
});
})();
<?php if (!empty($_mpSiguienteTomAt)): ?>
// Fix 1: recuperar countdown si el modal fue cerrado y reabierto mientras una toma estaba en curso
(function() {