diff --git a/api/lab/save_tomas_config.php b/api/lab/save_tomas_config.php
new file mode 100644
index 0000000..22416a3
--- /dev/null
+++ b/api/lab/save_tomas_config.php
@@ -0,0 +1,151 @@
+fetch("SELECT id, esquema FROM lab_formularios WHERE id = 15 LIMIT 1");
+if (!$row) jsonError('Formulario de tomas no encontrado.', 404);
+
+$esquema = json_decode($row['esquema'], true);
+if (!is_array($esquema)) jsonError('Esquema del formulario no válido.', 500);
+
+// ── Localizar el campo selector de tipo de examen ────────────
+$idxSelector = null;
+foreach ($esquema as $i => $c) {
+ if (($c['id'] ?? '') === '_c8j2g16') { $idxSelector = $i; break; }
+}
+if ($idxSelector === null) jsonError('Campo selector de examen (_c8j2g16) no encontrado.', 500);
+
+// ── ADD EXAM ─────────────────────────────────────────────────
+if ($action === 'add_exam') {
+ $examName = trim($body['exam_name'] ?? '');
+ $tomas = $body['tomas'] ?? [];
+
+ if (!$examName) jsonError('exam_name requerido.');
+ if (strlen($examName) > 80) jsonError('exam_name demasiado largo (máx 80 chars).');
+ if (!is_array($tomas) || empty($tomas)) jsonError('tomas requeridas.');
+ if (count($tomas) > 20) jsonError('Máximo 20 tomas por examen.');
+
+ // Verificar que el examen no exista ya
+ $currentOptions = $esquema[$idxSelector]['options'] ?? [];
+ if (in_array($examName, $currentOptions, true)) {
+ jsonError("El tipo de examen '$examName' ya existe.");
+ }
+
+ // Validar tomas
+ foreach ($tomas as $i => $t) {
+ $tipo = $t['tipo'] ?? '';
+ $valor = $t['valor'] ?? '';
+ $label = trim($t['label'] ?? '');
+ if (!in_array($tipo, ['minutos', 'hora_fija'], true)) jsonError("Toma $i: tipo inválido.");
+ if (!$label) jsonError("Toma $i: label requerido.");
+ if ($tipo === 'minutos' && (!is_numeric($valor) || (int)$valor < 0))
+ jsonError("Toma $i: valor de minutos inválido.");
+ if ($tipo === 'hora_fija' && !preg_match('/^\d{1,2}:\d{2}$/', $valor))
+ jsonError("Toma $i: hora_fija debe ser HH:MM.");
+ }
+
+ // Prefijo corto para IDs (basado en nombre del examen, sanitizado)
+ $prefix = '_' . substr(preg_replace('/[^a-z0-9]/i', '', strtolower($examName)), 0, 8) . '_';
+ $uid = substr(md5($examName . microtime()), 0, 4);
+
+ // 1. Agregar opción al campo selector
+ $esquema[$idxSelector]['options'][] = $examName;
+
+ // 2. Agregar campos al final del esquema
+ $condCampoId = '_c8j2g16';
+ foreach ($tomas as $idx => $t) {
+ $label = trim($t['label']);
+ $tipo = $t['tipo'];
+ $valor = $t['valor'];
+
+ // Construir label del separador
+ if ($tipo === 'minutos') {
+ $sepLabel = "$examName · Minuto $valor";
+ } else {
+ // hora_fija: convertir HH:MM a "H:MM a.m./p.m."
+ [$hh, $mm] = explode(':', $valor);
+ $h = (int)$hh; $ampm = $h >= 12 ? 'p.m.' : 'a.m.';
+ $h12 = $h > 12 ? $h - 12 : ($h === 0 ? 12 : $h);
+ $sepLabel = "$examName · {$h12}:{$mm} {$ampm}";
+ }
+
+ $sepId = $prefix . 's' . $idx . $uid;
+ $horaId = $prefix . 'h' . $idx . $uid;
+ $resId = $prefix . 'r' . $idx . $uid;
+ $firmaId = $prefix . 'f' . $idx . $uid;
+
+ $esquema[] = [
+ 'id' => $sepId,
+ 'tipo' => 'separador',
+ 'label' => $sepLabel,
+ 'condicion'=> ['campo_id' => $condCampoId, 'valores' => [$examName]],
+ ];
+ $esquema[] = [
+ 'id' => $horaId,
+ 'tipo' => 'hora',
+ 'label' => 'Hora de toma',
+ ];
+ $esquema[] = [
+ 'id' => $resId,
+ 'tipo' => 'numero',
+ 'label' => 'Resultado',
+ ];
+ $esquema[] = [
+ 'id' => $firmaId,
+ 'tipo' => 'firma_profesional',
+ 'label' => 'Firma del profesional',
+ ];
+ }
+
+ $db->getConnection()->prepare(
+ "UPDATE lab_formularios SET esquema = ? WHERE id = 15"
+ )->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
+
+ jsonOk([
+ 'exam_name' => $examName,
+ 'tomas_count' => count($tomas),
+ 'options_count'=> count($esquema[$idxSelector]['options']),
+ ], "Examen '$examName' agregado con " . count($tomas) . " tomas.");
+}
+
+// ── DELETE EXAM ──────────────────────────────────────────────
+if ($action === 'delete_exam') {
+ $examName = trim($body['exam_name'] ?? '');
+ if (!$examName) jsonError('exam_name requerido.');
+
+ $options = $esquema[$idxSelector]['options'] ?? [];
+ if (!in_array($examName, $options, true)) jsonError("El examen '$examName' no existe.");
+
+ // Eliminar opción del selector
+ $esquema[$idxSelector]['options'] = array_values(array_filter($options, fn($o) => $o !== $examName));
+
+ // Eliminar separadores condicionados únicamente a este examen
+ $esquema = array_values(array_filter($esquema, function($c) use ($examName) {
+ $cond = $c['condicion'] ?? null;
+ if (!$cond) return true;
+ $vals = $cond['valores'] ?? [];
+ return !(count($vals) === 1 && $vals[0] === $examName);
+ }));
+
+ $db->getConnection()->prepare(
+ "UPDATE lab_formularios SET esquema = ? WHERE id = 15"
+ )->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
+
+ jsonOk(['exam_name' => $examName], "Examen '$examName' eliminado.");
+}
diff --git a/lab_tomas_config.php b/lab_tomas_config.php
new file mode 100644
index 0000000..025c678
--- /dev/null
+++ b/lab_tomas_config.php
@@ -0,0 +1,304 @@
+fetch("SELECT esquema FROM lab_formularios WHERE id = 15 LIMIT 1");
+if (!$row) { die('Formulario de tomas no encontrado (id=15).'); }
+
+$esquema = json_decode($row['esquema'], true) ?? [];
+
+// Leer campo selector de examen
+$selectorCampo = null;
+foreach ($esquema as $c) {
+ if (($c['id'] ?? '') === '_c8j2g16') { $selectorCampo = $c; break; }
+}
+$examOptions = $selectorCampo['options'] ?? [];
+
+// Agrupar tomas por tipo de examen (leer la misma lógica del $_mpMap)
+$gruposActuales = [];
+$curLabel = null; $curCond = null; $curSepId = null;
+$curMin = null; $curHoraFija = null; $curHora = null;
+foreach ($esquema as $c) {
+ $tipo = $c['tipo'] ?? '';
+ if ($tipo === 'separador') {
+ $curLabel = $c['label'] ?? '';
+ $curSepId = $c['id'] ?? null;
+ $curCond = $c['condicion'] ?? null;
+ $curMin = $curHoraFija = $curHora = null;
+ if (preg_match('/[Mm]inuto\s+(\d+)/u', $curLabel, $mx)) {
+ $curMin = (int)$mx[1];
+ } elseif (preg_match('/(\d{1,2}):(\d{2})\s*(a\.?m\.?|p\.?m\.?)/i', $curLabel, $mx)) {
+ $h = (int)$mx[1]; $m2 = (int)$mx[2];
+ $pm = strtolower(preg_replace('/[^apm]/i', '', $mx[3])) === 'pm';
+ if ($pm && $h < 12) $h += 12;
+ $curHoraFija = sprintf('%02d:%02d', $h, $m2);
+ $curMin = PHP_INT_MAX;
+ }
+ } elseif ($tipo === 'hora' && ($curMin !== null || $curHoraFija !== null)) {
+ $curHora = $c['id'] ?? null;
+ } elseif ($tipo === 'firma_profesional' && $curHora !== null && ($curMin !== null || $curHoraFija !== null)) {
+ // Determinar a qué examen pertenece
+ $examKey = 'Sin condición';
+ if ($curCond) {
+ $vals = $curCond['valores'] ?? [];
+ $examKey = count($vals) === 1 ? $vals[0] : implode(' / ', $vals);
+ } else {
+ // Label-based: prefijo antes de "·"
+ $examKey = trim(explode('·', $curLabel)[0]);
+ }
+ if (!isset($gruposActuales[$examKey])) $gruposActuales[$examKey] = [];
+ $gruposActuales[$examKey][] = [
+ 'label' => $curLabel,
+ 'sep_id' => $curSepId,
+ 'hora_fija' => $curHoraFija,
+ 'min' => $curMin === PHP_INT_MAX ? null : $curMin,
+ 'firma_id' => $c['id'],
+ ];
+ $curHora = null;
+ }
+}
+
+Layout::open('Configurar Tomas Prolongadas', 'fas fa-vials');
+?>
+
+
+
+
+
+
+
+
+
No hay grupos de tomas configurados.
+
+
+ $tomas): ?>
+
+
+
+
+
+
+
+ | # | Label | Tiempo | Firma ID |
+
+
+ $t): ?>
+
+ | = $i + 1 ?> |
+ = htmlspecialchars($t['label']) ?> |
+
+
+ Fija = htmlspecialchars($t['hora_fija']) ?>
+
+ Min = (int)$t['min'] ?>
+
+ |
+ = htmlspecialchars($t['firma_id']) ?> |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Debe ser único. Aparecerá en el selector de examen del formulario.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Defina el nombre exacto del examen (aparecerá en el checkbox del formulario).
+ - Minutos: tiempo en minutos desde la primera toma (ej: 0, 30, 60).
+ - Hora fija: hora exacta de toma (ej: 15:00 para 3pm).
+ - No mezcle minutos y hora fija en el mismo examen.
+ - Para eliminar un examen: expanda el grupo y use el botón Eliminar.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/turnero/api/configurar_tomas.php b/modules/turnero/api/configurar_tomas.php
new file mode 100644
index 0000000..cc566d7
--- /dev/null
+++ b/modules/turnero/api/configurar_tomas.php
@@ -0,0 +1,47 @@
+ $ids) {
+ if (!is_array($ids) || empty($ids)) jsonError("El grupo '$grupo' no tiene tomas seleccionadas.");
+ foreach ($ids as $id) {
+ if (!preg_match('/^[a-zA-Z0-9_]+$/', $id)) jsonError("ID de firma inválido: $id");
+ }
+}
+
+$pdo = db();
+$stmt = $pdo->prepare(
+ "SELECT id, datos_respuestas FROM turnero_consentimientos WHERE token = ? LIMIT 1"
+);
+$stmt->execute([$token]);
+$tc = $stmt->fetch(PDO::FETCH_ASSOC);
+if (!$tc) jsonError('Consentimiento no encontrado.', 404);
+
+$dr = [];
+if ($tc['datos_respuestas']) {
+ $d = json_decode($tc['datos_respuestas'], true);
+ if (is_array($d)) $dr = $d;
+}
+
+$dr['_tomas_config'] = $cfg;
+
+$pdo->prepare("UPDATE turnero_consentimientos SET datos_respuestas = ? WHERE id = ?")
+ ->execute([json_encode($dr, JSON_UNESCAPED_UNICODE), $tc['id']]);
+
+jsonOk([], 'Configuración de tomas guardada.');
diff --git a/modules/turnero/api/guardar_toma.php b/modules/turnero/api/guardar_toma.php
index df199a1..f629852 100644
--- a/modules/turnero/api/guardar_toma.php
+++ b/modules/turnero/api/guardar_toma.php
@@ -101,6 +101,16 @@ foreach ($esquema as $campo) {
}
}
+// ── Aplicar filtro de ciclos configurados (tomas_config) ────
+$__tcFirmas = $datosMerge['_tomas_config'] ?? null;
+if (is_array($__tcFirmas)) {
+ $__allowed = [];
+ foreach ($__tcFirmas as $__g => $__ids) {
+ if (is_array($__ids)) foreach ($__ids as $__id) $__allowed[$__id] = true;
+ }
+ $firmasCampos = array_values(array_filter($firmasCampos, fn($f) => isset($__allowed[$f['id']])));
+}
+
// ── Determinar estado y siguiente_toma_at ────────────────────
// Acepta firma guardada con clave campo_id O campo_id_svg (compatibilidad registros históricos)
$hasFirma = fn($f) => (isset($datosMerge[$f['id']]) && strlen($datosMerge[$f['id']]) > 10)
diff --git a/ver_formulario_enviado.php b/ver_formulario_enviado.php
index c26415a..3a1a9d7 100644
--- a/ver_formulario_enviado.php
+++ b/ver_formulario_enviado.php
@@ -248,14 +248,19 @@ $_soloFirmaPro = ($modoTurnero && !empty($tcRow['es_toma_progresiva']))
// Muestras Prolongadas: detectar secciones hora+firma_profesional con intervalos de minutos.
// Agrupa por condición del separador para no mezclar Glicemia y Cortisol (ambas usan prefijo "Toma").
-$_mpMap = [];
+$_mpMap = [];
+$_mpGroupsFull = []; // grupos sin filtrar (para la pantalla de configuración)
+$_mpSkippedSepIds = []; // separadores de tomas no seleccionadas (se ocultarán en render)
+$_tomasConfig = null; // configuración guardada por paciente ({grupo:[firmaId,...]})
+
if ($modoTurnero && $embebido && $_soloFirmaPro) {
$_mpSecs = []; $_mpLabel = null; $_mpMin = null; $_mpHoraFija = null; $_mpHora = null;
- $_mpCondKey = null; $_mpCondDisplay = null;
+ $_mpCondKey = null; $_mpCondDisplay = null; $_mpSepId = null;
foreach ($esquema as $_c) {
$_t = $_c['tipo'] ?? '';
if ($_t === 'separador') {
$_mpLabel = $_c['label'] ?? ''; $_mpMin = null; $_mpHoraFija = null; $_mpHora = null;
+ $_mpSepId = $_c['id'] ?? null;
$_mpCond = $_c['condicion'] ?? null;
// Clave de agrupación: usa valores de condición para distinguir Glicemia vs Cortisol
if ($_mpCond) {
@@ -279,6 +284,7 @@ if ($modoTurnero && $embebido && $_soloFirmaPro) {
} elseif ($_t === 'firma_profesional' && $_mpHora !== null && ($_mpMin !== null || $_mpHoraFija !== null)) {
$_mpSecs[] = [
'label' => $_mpLabel,
+ 'sep_id' => $_mpSepId,
'min' => $_mpMin ?? PHP_INT_MAX,
'hora_fija' => $_mpHoraFija,
'hora' => $_mpHora,
@@ -291,6 +297,26 @@ if ($modoTurnero && $embebido && $_soloFirmaPro) {
}
$_mpGroups = [];
foreach ($_mpSecs as $_s) { $_mpGroups[$_s['tipo']][] = $_s; }
+ $_mpGroupsFull = $_mpGroups;
+
+ // Aplicar filtro de ciclos si el profesional ya configuró las tomas para este paciente
+ $__rawCfg = $datosCliente['_tomas_config'] ?? null;
+ if (is_array($__rawCfg)) {
+ $_tomasConfig = $__rawCfg;
+ foreach ($_mpGroups as $__gk => &$__gsecs) {
+ if (!isset($_tomasConfig[$__gk])) continue;
+ $__allowed = array_flip($_tomasConfig[$__gk]);
+ foreach ($__gsecs as $__s) {
+ if (!isset($__allowed[$__s['firma']])) {
+ $_mpSkippedSepIds[$__s['sep_id']] = true;
+ }
+ }
+ $__gsecs = array_values(array_filter($__gsecs, fn($s) => isset($__allowed[$s['firma']])));
+ }
+ unset($__gsecs);
+ }
+
+ // Construir $_mpMap desde grupos (posiblemente filtrados)
foreach ($_mpGroups as $_tipo => $_secs) {
for ($i = 0, $n = count($_secs); $i < $n; $i++) {
$_s = $_secs[$i]; $_nx = $_secs[$i + 1] ?? null;
@@ -513,6 +539,24 @@ function esc2(mixed $v): string {
display:inline-flex; align-items:center; gap:5px; transition:all .15s; }
.btn-topaz:hover { background:#e0f2fe; }
@keyframes mp-pulse { 0%,100%{opacity:1} 50%{opacity:.65} }
+
+ /* ── Panel configuración de ciclos ─────────────────── */
+ .mp-cfg-overlay { position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.45);
+ display:flex;align-items:flex-start;justify-content:center;
+ padding:1rem;overflow-y:auto; }
+ .mp-cfg-card { background:#fff;border-radius:14px;max-width:500px;width:100%;
+ box-shadow:0 12px 48px rgba(0,0,0,.3);margin-top:.5rem; }
+ .mp-cfg-head { background:= htmlspecialchars($docColor) ?>;color:#fff;
+ padding:1rem 1.25rem;border-radius:14px 14px 0 0;
+ font-size:1rem;font-weight:700; }
+ .mp-cfg-body { padding:1.25rem; }
+ .mp-cfg-foot { padding:0 1.25rem 1.25rem; }
+ .mp-cfg-gtitle { font-weight:700;font-size:.88rem;color:#1e3a5f;margin-bottom:.5rem;
+ border-left:3px solid = htmlspecialchars($docColor) ?>;padding-left:.5rem; }
+ .mp-cfg-item { border:1.5px solid #e5e7eb;border-radius:8px;padding:.35rem .6rem;
+ transition:all .15s;cursor:pointer;user-select:none; }
+ .mp-cfg-item:has(.form-check-input:checked) { background:#f0fdf4;border-color:#86efac; }
+ .mp-cfg-item label { cursor:pointer; }
@@ -601,6 +645,70 @@ function esc2(mixed $v): string {
+ $_gsecs) {
+ if (empty($_gsecs)) continue;
+ $__parts = array_values(array_filter(explode('|', $_gk)));
+ $__isCond = !empty(array_intersect($__parts, $_examOptions));
+ $__matchSel = !empty(array_intersect($__parts, $_examSel));
+ if (!$__isCond || $__matchSel) {
+ $__title = implode(' / ', array_intersect($__parts, $_examSel));
+ if (!$__title) $__title = $_gsecs[0]['display'] ?? $_gk;
+ $_gruposConfig[$_gk] = ['title' => $__title, 'secs' => $_gsecs];
+ }
+ }
+ $_showCfgPanel = $modoTurnero && $embebido && !empty($_gruposConfig) && $_tomasConfig === null;
+ ?>
+
+
+
+
+ Configurar protocolo de tomas
+
+
+
+ Seleccione las tomas a realizar para este paciente.
+ Puede desmarcar las tomas que no apliquen según la indicación médica.
+
+ $_cg): ?>
+
+
= esc2($_cg['title']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -674,6 +782,10 @@ function esc2(mixed $v): string {
} else {
$_saltarSeccion = false;
}
+ // Ocultar secciones de tomas excluidas por la configuración de ciclos del paciente
+ if (!$_saltarSeccion && isset($_mpSkippedSepIds[$campo['id'] ?? ''])) {
+ $_saltarSeccion = true;
+ }
if ($_saltarSeccion) continue; ?>
= esc2($campo['label'] ?? '') ?>
+
+/* ── Panel de configuración de ciclos ── */
+(function() {
+ var btn = document.getElementById('mp-cfg-iniciar');
+ if (!btn) return;
+ btn.addEventListener('click', function() {
+ var byGroup = {};
+ document.querySelectorAll('.mp-cfg-chk').forEach(function(chk) {
+ var g = chk.dataset.group;
+ if (!byGroup[g]) byGroup[g] = { total: 0, sel: [] };
+ byGroup[g].total++;
+ if (chk.checked) byGroup[g].sel.push(chk.value);
+ });
+ var config = {};
+ for (var g in byGroup) {
+ if (byGroup[g].sel.length === 0) {
+ document.getElementById('mp-cfg-msg').innerHTML =
+ '
Seleccione al menos una toma por protocolo.';
+ return;
+ }
+ config[g] = byGroup[g].sel;
+ }
+ btn.disabled = true;
+ btn.innerHTML = '
Guardando...';
+ var base = window.location.href.split('/ver_formulario_enviado.php')[0];
+ fetch(base + '/modules/turnero/api/configurar_tomas.php', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: = json_encode($tokenTurnero) ?>, tomas_config: config })
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(d) {
+ if (d.ok) { location.reload(); }
+ else {
+ document.getElementById('mp-cfg-msg').innerHTML =
+ '
Error: ' + (d.error || 'Error desconocido') + '';
+ btn.disabled = false;
+ btn.innerHTML = '
Iniciar protocolo';
+ }
+ })
+ .catch(function() {
+ document.getElementById('mp-cfg-msg').innerHTML = '
Error de conexión.';
+ btn.disabled = false;
+ btn.innerHTML = '
Iniciar protocolo';
+ });
+ });
+})();
+
window._muestrasMap = = json_encode($_mpMap, JSON_UNESCAPED_UNICODE) ?>;
window._mpSavedFirmas = {};