From 2eefe3f5c812c016ba0cbb1d14c9daf005bf671a Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:19:11 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20ciclos=20configurables=20+=20gesti?= =?UTF-8?q?=C3=B3n=20de=20ex=C3=A1menes=20en=20Tomas=20Prolongadas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 1 — Ciclos configurables por paciente: - ver_formulario_enviado.php: pantalla de configuración aparece la primera vez que el profesional abre el formulario (antes de firmar); permite seleccionar qué tomas realizar para cada protocolo. $_mpSkippedSepIds oculta las secciones no seleccionadas en render. $_mpMap se reconstruye desde los grupos filtrados para que next_firma_id salte tomas omitidas. - configurar_tomas.php (nuevo): guarda _tomas_config en datos_respuestas del consentimiento; valida que cada grupo tenga al menos una toma. - guardar_toma.php: aplica el filtro _tomas_config al array firmasCampos para que el conteo de tomas firmadas/pendientes sea correcto. Feature 2 — Gestión de tipos de examen (admin): - lab_tomas_config.php (nuevo): página admin que muestra los exámenes actuales con sus tomas y permite añadir nuevos tipos con su esquema completo (separador + hora + resultado + firma_profesional por toma). - api/lab/save_tomas_config.php (nuevo): modifica el esquema JSON del formulario id=15; add_exam agrega opción al selector y los campos de cada toma; delete_exam elimina la opción y los separadores exclusivos. Co-Authored-By: Claude Sonnet 4.6 --- api/lab/save_tomas_config.php | 151 +++++++++++ lab_tomas_config.php | 304 +++++++++++++++++++++++ modules/turnero/api/configurar_tomas.php | 47 ++++ modules/turnero/api/guardar_toma.php | 10 + ver_formulario_enviado.php | 163 +++++++++++- 5 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 api/lab/save_tomas_config.php create mode 100644 lab_tomas_config.php create mode 100644 modules/turnero/api/configurar_tomas.php 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'); +?> + +
+ +
+
+
+ Tipos de examen configurados + tipos +
+
+ +

No hay grupos de tomas configurados.

+ +
+ $tomas): ?> + +
+

+ +

+
+
+ + + + + + $t): ?> + + + + + + + + +
#LabelTiempoFirma ID
+ + Fija + + Min + +
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+
+ Agregar nuevo tipo de examen +
+
+
+ + +
Debe ser único. Aparecerá en el selector de examen del formulario.
+
+ + +
+ +
+ + +
+ +
+
+
+ +
+
+ Instrucciones +
+
+
    +
  • 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:;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 ;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): ?> +
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
@@ -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; ?>
+ +/* ── 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: , 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 = ; window._mpSavedFirmas = {};